Subversion Repositories zfs_utils

Rev

Rev 20 | 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
   my ($error, $output ) = &run( $command );
21 rodolico 120
   die "Error running $command with output [$output]\nMisconfigured Dataset?\n" if $error;
2 rodolico 121
   my @snaps = split( "\n", $output );
122
   chomp @snaps;
123
   for (my $i = 0; $i < @snaps; $i++ ) {
124
      # parse out the space delmited fields
125
      my ($fullname, $used, $avail, $refer, $mount) = split( /\s+/, $snaps[$i] );
126
      # break the name into dataset and snapname
127
      my ($dataset, $snap) = split( '@', $fullname );
128
      # remove the root dataset name
129
      $dataset =~ s/^$config->{'dataset'}//;
130
      # skip anything not matching our regex
131
      next unless $pattern && $snap && $snap =~ m/$pattern/;
132
      # grab the matched key
133
      $return{$dataset}{'snaps'}{$snap}{'key'} = $1;
134
      # and remove all non-numerics
135
      $return{$dataset}{'snaps'}{$snap}{'key'} =~ s/[^0-9]//g;
136
      # get the transfer size
137
      $return{$dataset}{'snaps'}{$snap}{'refer'} = $refer;
138
      # get the actual disk space used
139
      $return{$dataset}{'snaps'}{$snap}{'used'} = $used;
140
   }
141
   return \%return;
142
}
143
 
20 rodolico 144
# Calculates the number of bytes that would be transferred for the next sync.
145
# Returns the size in bytes, or 0 if datasets are up to date.
6 rodolico 146
sub findSize {
147
   my $config = shift;
148
   # check for new snapshots to sync. If they are equal, we are up to date
149
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
150
      # Build the source command
151
      my $sourceCommand = sprintf( '%s@%s %s@%s', 
152
                               $config->{'source'}->{'dataset'},
153
                               $config->{'target'}->{'lastSnap'},
154
                               $config->{'source'}->{'dataset'},
155
                               $config->{'source'}->{'lastSnap'}
156
                           );
157
      # prepend 'zfs send' and the flags. Note that verbose is only for the one which is local
158
      $sourceCommand = 'zfs send -' . 
159
                  ( $config->{'recurse'} ? 'R' : '' ) . # recurse if they asked for it
15 rodolico 160
                  # Tell it to give us the size in bytes
8 rodolico 161
                  'Pn' .
6 rodolico 162
                  # this is the part that asks for incremental
163
                  'I ' .
164
                  $sourceCommand;
165
      # wrap the ssh call if this is remote
166
      $sourceCommand = "ssh $config->{source}->{server} '$sourceCommand'" if  $config->{'source'}->{'server'};
15 rodolico 167
      print "Checking Size with\n$sourceCommand\n" if $config->{'verbose'} > 3;
6 rodolico 168
      my ( $error, $output ) = &run( $sourceCommand );
169
      return -1 if $error;
170
      # the size is the second column (tab separated) of the last line (\n separated) in $output
171
      return ( 
172
               split( 
173
                  "\t",
174
                  (
175
                     split( "\n", $output )
176
                  )[-1]
177
               )
178
            )[1];
179
   } else { # nothing to sync
180
      return 0;
181
   }
182
}
2 rodolico 183
 
20 rodolico 184
# Builds the shell command(s) needed to replicate the ZFS snapshot(s)
185
# from source to target, using zfs send/receive and optionally pv.
2 rodolico 186
sub createCommands {
6 rodolico 187
   my $config = shift;
188
   # check for new snapshots to sync. If they are equal, we are up to date
189
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
190
      # Build the source command
191
      my $sourceCommand = sprintf( '%s@%s %s@%s', 
192
                               $config->{'source'}->{'dataset'},
193
                               $config->{'target'}->{'lastSnap'},
194
                               $config->{'source'}->{'dataset'},
195
                               $config->{'source'}->{'lastSnap'}
196
                           );
197
      # prepend 'zfs send' and the flags. Note that verbose is only for the one which is local
198
      $sourceCommand = 'zfs send -' . 
199
                  ( $config->{'recurse'} ? 'R' : '' ) . # recurse if they asked for it
200
                  # turn on verbose if they asked for level 2 AND if source is local
15 rodolico 201
                  ( $config->{'verbose'} > 2 && ! $config->{'source'}->{'server'} ? 'v' : '' ) .
6 rodolico 202
                  # this is the part that asks for incremental
203
                  'I ' .
204
                  $sourceCommand;
205
      # wrap the ssh call if this is remote
206
      $sourceCommand = "ssh $config->{source}->{server} '$sourceCommand'" if  $config->{'source'}->{'server'};
207
      # Now, build the target command
208
      my $targetCommand = 'zfs receive ' . 
15 rodolico 209
                          ( ! $config->{'target'}->{'server'} && $config->{'verbose'} > 2 ? '-v ' : '') .
6 rodolico 210
                          $config->{'target'}->{'dataset'};
211
      $targetCommand = "ssh $config->{target}->{server} '$targetCommand'" if  $config->{'target'}->{'server'};
7 rodolico 212
      # if the command pv is installed
213
      if ( `which pv` ) {
214
         my $tags;
215
         # add bandwdith limits, if requested
216
         $tags = " --si -L $config->{bwlimit} " if $config->{'bwlimit'};
217
         # if interactive, or if we are in dry run, add thermometer
218
         $tags .= '-petrs ' . $config->{'report'}->{'Bytes Transferred'} if -t *STDOUT || $config->{'dryrun'};
219
         $sourceCommand .= " | pv $tags" if $tags;
220
      }
6 rodolico 221
      # return the command
222
      return $sourceCommand . ' | ' . $targetCommand;
223
   } else { # source and target are in sync, so do nothing
224
      return '# Nothing new to sync';
2 rodolico 225
   }
226
}
227
 
20 rodolico 228
# Finds the most recent snapshot in a hash of snapshots.
229
# Returns the snapshot name with the largest 'key' value.
2 rodolico 230
sub getLastSnapshot {
231
   my $snapList = shift;
232
   my $lastKey = 0;
233
   my $lastSnap = '';
234
   foreach my $snap ( keys %$snapList ) {
235
      if ( $snapList->{$snap}->{'key'} > $lastKey ) {
236
         $lastKey = $snapList->{$snap}->{'key'};
237
         $lastSnap = $snap;
238
      }
239
   }
240
   return $lastSnap;
241
}
242
 
20 rodolico 243
# Checks if all child datasets have all the snapshots the parent has.
244
# If not, returns a list of datasets to replicate individually.
245
sub check_child_snap_consistency {
246
    my ($config, $side) = @_;
21 rodolico 247
    #print Dumper( $config ) . "\n"; die;
248
    my $snaps = $config->{$side}->{'snapshots'};
249
    #print Dumper( $snaps ) . "\n"; die;
20 rodolico 250
    my @datasets = keys %$snaps;
21 rodolico 251
    #die Dumper( \@datasets ) . "\n";
20 rodolico 252
    return @datasets if @datasets == 1; # Only parent, nothing to check
2 rodolico 253
 
20 rodolico 254
    my $parent = (sort @datasets)[0]; # Assume parent is first (no / in name or shortest)
21 rodolico 255
    #die Dumper( \@datasets ) . "\n";
20 rodolico 256
    my %parent_snaps = %{ $snaps->{$parent}{'snaps'} };
257
 
258
    my @inconsistent;
259
    foreach my $child (@datasets) {
260
        next if $child eq $parent;
261
        foreach my $snap (keys %parent_snaps) {
262
            unless (exists $snaps->{$child}{'snaps'}{$snap}) {
263
                push @inconsistent, $child;
264
                last;
265
            }
266
        }
267
    }
268
    if (@inconsistent) {
269
        # Return all datasets as separate jobs
270
        return @datasets;
271
    } else {
272
        # All children have all parent snaps, treat as one job
273
        return ($parent);
274
    }
275
}
276
 
277
# Calculates the last snapshot for source and target, and checks for consistency.
278
# Returns (source_last_snap, target_last_snap, warnings_arrayref).
2 rodolico 279
sub calculate {
280
   my $config = shift;
281
 
282
   my @warnings;
283
 
284
   # find the last snapshot date in each dataset, on each target
285
   foreach my $machine ( 'source', 'target' ) {
286
      $config->{$machine}->{'last'} = 0; # track the last entry in all children in dataset
287
      $config->{$machine}->{'allOk'} = 1; # assumed to be true, becomes false if some children do not have snapshots
288
      foreach my $child ( keys %{ $config->{$machine}->{'snapshots'} } ) {
289
         $config->{$machine}->{'snapshots'}->{$child}->{'last'} = 
290
            &getLastSnapshot( $config->{$machine}->{'snapshots'}->{$child}->{'snaps'} );
291
         # set the machine last if we haven't done so yet
292
         $config->{$machine}->{'last'} = $config->{$machine}->{'snapshots'}->{$child}->{'last'} unless $config->{$machine}->{'last'};
293
         # keep track of the last snapshot for each set
294
         if ( $config->{$machine}->{'last'} ne $config->{$machine}->{'snapshots'}->{$child}->{'last'} ) {
295
            $config->{$machine}->{'allOk'} = 0;
296
            push @warnings, "Warning: $machine does not have consistent snapshots at $child";;
297
         }
298
      }
299
   }
300
   # make sure the source has a corresponding snap for target->last
301
   foreach my $child ( keys %{ $config->{'target'}->{'snapshots'} } ) {
302
      if (! exists ($config->{'source'}->{'snapshots'}->{$child}->{'snaps'}->{$config->{'target'}->{'snapshots'}->{$child}->{'last'}} ) ) {
303
         $config->{'source'}->{'allOk'} = 0;
304
         push @warnings, "Warning: We  do not have consistent snapshots";
305
      }
306
   }
307
   my $return;
308
   if ( $config->{'source'}->{'allOk'} and $config->{'target'}->{'allOk'} ) { # whew, they match
309
      return( $config->{'source'}->{'last'}, $config->{'target'}->{'last'}, \@warnings );
310
   } else {
311
      return( '','',\@warnings);
312
   }
4 rodolico 313
} # sub calculate
2 rodolico 314
 
20 rodolico 315
# Prints usage/help message and exits.
6 rodolico 316
sub help {
317
   use File::Basename;
318
   my $me = fileparse( $0 );
319
   my $helpMessage = <<"   EOF";
320
      $me [flags] [source [target]]
20 rodolico 321
      Version $VERSION
6 rodolico 322
         Syncs source dataset to target dataset
323
 
324
      Parameters (optional)
325
         source - dataset syncing from
326
         target - dataset syncing to
327
 
328
      Flags
329
         --source|s  - Alternate way to pass source dataset
330
         --target|t  - Alternate way to pass target dataset
331
         --filter|f  - Filter (regex) to limit source snapshots to process
332
         --dryrun|n  - Only displays command(s) to be run
333
         --recurse|r - Process dataset and all child datasets
334
         --verbose|v - increase verbosity of output
7 rodolico 335
         --bwlimit   - Limit the speed of the connect to # bytes/s. KMGT allowed 
20 rodolico 336
         --version|V - display the version number and exit
6 rodolico 337
 
338
      May use short flags with bundling, ie -nrvv is valid for 
339
      --dryrun --recurse --verbose --verbose
340
 
341
      Either source or target must contain a DNS name or IP address of a remote
342
      machine, separated from the dataset with a colon, ie
343
         --source fbsd:storage/mydata
344
      would use the dataset storage/mydata on the server fbsd. The other dataset
345
      is assumed to be the local machine
346
 
347
      filter is a string which is a valid regular expression. Only snapshots matching
348
      that string will be used from the source dataset
349
 
350
      By default, only error messages are displayed. verbose will display statistics
15 rodolico 351
      on size and transfer time. Twice will give the commands, and three times will 
352
      display entire output of send/receive (whichever is the local machine)
7 rodolico 353
 
354
      Example:
355
         $me -r prod.example.org:pool/mydata -t pool/backup/mydata \
356
            --bwlimit=5M --filter='(\\d{4}.\\d{2}.\\d{2}.\\d{2}.\\d{2})'
357
 
358
         Would sync pool/mydata and all child datasets on prod.example.org to
359
         pool/backup/mydata on the local server. Only the snapshots which had a
360
         datetime stamp matching the --filter rule would be used. The transfer
361
         would not exceed 5MB/s (40Mb/s) if the pv app was installed
6 rodolico 362
   EOF
363
   # get rid of indentation
364
   $helpMessage =~ s/^      //;
365
   $helpMessage =~ s/\n      /\n/g;
366
   print $helpMessage;
367
   exit 1;
368
} # help
369
 
370
 
4 rodolico 371
GetOptions( $config,
372
   'source|s=s',
373
   'target|t=s',
374
   'filter|f=s',
375
   'dryrun|n',
376
   'recurse|r',
8 rodolico 377
   'bwlimit=s',
6 rodolico 378
   'verbose|v+',
20 rodolico 379
   'version|V',
4 rodolico 380
   'help|h'
381
);
2 rodolico 382
 
6 rodolico 383
&help() if $config->{'help'};
20 rodolico 384
if ($config->{'version'}) {
385
   print "replicate version $VERSION\n" ;
386
   exit 0;
387
}
4 rodolico 388
# allow them to use positional, without flags, such as
389
# replicate source target --filter='regex' -n
390
$config->{'source'} = shift unless $config->{'source'};
391
$config->{'target'} = shift unless $config->{'target'};
392
die "You must enter a source and a target, at a minimum\n" unless $config->{'source'} && $config->{'target'};
393
 
6 rodolico 394
# keep track of when we started this run
395
$config->{'report'}->{'Start Time'} = time;
396
 
4 rodolico 397
# WARNING: this converts source and targets from a string to a hash
398
# '10.0.0.1:data/set' becomes ( 'server' => '10.0.0.1', 'dataset' => 'data/set')
399
# and 'data/set' becomes ( 'server' => '', 'dataset' => 'data/set')
400
$config->{'source'} = &parseDataSet( $config->{'source'} );
401
$config->{'target'} = &parseDataSet( $config->{'target'} );
402
 
2 rodolico 403
# both source and target can not have a server portion; one must be local
404
die "Source and Target can not both be remote\n" if $config->{'source'}->{'server'} && $config->{'target'}->{'server'};
405
 
6 rodolico 406
# connect to servers and get all existing snapshots
407
$config->{'target'}->{'snapshots'} = &getSnaps( $config->{'target'}, $config->{'filter'} );
4 rodolico 408
$config->{'source'}->{'snapshots'} = &getSnaps( $config->{'source'}, $config->{'filter'} );
2 rodolico 409
 
20 rodolico 410
# Check for child dataset snapshot consistency on source and target
411
my @source_jobs = check_child_snap_consistency($config, 'source');
412
my @target_jobs = check_child_snap_consistency($config, 'target');
2 rodolico 413
 
20 rodolico 414
# If either side has inconsistencies, break into per-dataset jobs
415
my %all_jobs;
416
$all_jobs{$_}++ for (@source_jobs, @target_jobs);
2 rodolico 417
 
20 rodolico 418
foreach my $dataset (sort keys %all_jobs) {
419
    # Prepare a config for this dataset
420
    my %job_config = %$config;
421
    $job_config{'source'} = { %{$config->{'source'}}, 'dataset' => $config->{'source'}{'dataset'} . $dataset, 'snapshots' => { $dataset => $config->{'source'}{'snapshots'}{$dataset} } };
422
    $job_config{'target'} = { %{$config->{'target'}}, 'dataset' => $config->{'target'}{'dataset'} . $dataset, 'snapshots' => { $dataset => $config->{'target'}{'snapshots'}{$dataset} } };
423
 
424
    ( $job_config{'source'}{'lastSnap'}, $job_config{'target'}{'lastSnap'} ) = &calculate( \%job_config );
425
    $job_config{'report'}{'Bytes Transferred'} = &findSize( \%job_config ) if $config->{'verbose'};
426
    my $commands = &createCommands( \%job_config );
427
    print "$commands\n" if $config->{'verbose'} > 1 or $config->{'dryrun'};
428
    if ( $config->{'dryrun'} ) {
429
        print "Dry Run for $dataset\n";
430
    } else {
431
        print qx/$commands/ if $commands =~ m/^[a-zA-Z]/;
432
    }
6 rodolico 433
}
434
 
435
$config->{'report'}->{'End Time'} = time;
436
$config->{'report'}->{'Elapsed Time'} = $config->{'report'}->{'End Time'} - $config->{'report'}->{'Start Time'};
15 rodolico 437
if ( $config->{'verbose'}  ) {
4 rodolico 438
   if ( $config->{'dryrun'} ) {
6 rodolico 439
      print "Would have transferred $config->{'report'}->{'Bytes Transferred'} bytes\n";
18 rodolico 440
   } elsif ( $config->{'report'}->{'Bytes Transferred'} ) {
441
      print "bytes\t$config->{'report'}->{'Bytes Transferred'}\nseconds\t$config->{'report'}->{'Elapsed Time'}\n";
2 rodolico 442
   } else {
18 rodolico 443
      print "Nothing to do, datasets up to date\n";
2 rodolico 444
   }
445
}
446
1;