Subversion Repositories zfs_utils

Rev

Rev 18 | Go to most recent revision | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 18 Rev 20
Line 1... Line 1...
1
#! /usr/bin/env perl
1
#! /usr/bin/env perl
2
 
2
 
-
 
3
# replicate
-
 
4
# Author: R. W. Rodolico
3
# very simple script to replicate a ZFS snapshot to another server.
5
# very simple script to replicate a ZFS snapshot to another server.
4
# no fancy bells and whistles, does not create snapshots, and does
6
# no fancy bells and whistles, does not create snapshots, and does
5
# not prune them. No major error checking either
7
# not prune them. No major error checking either
-
 
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
-
 
39
 
6
 
40
 
7
use strict;
41
use strict;
8
use warnings;
42
use warnings;
9
 
43
 
10
use Data::Dumper;
44
use Data::Dumper;
11
use Getopt::Long;
45
use Getopt::Long;
12
Getopt::Long::Configure ("bundling");
46
Getopt::Long::Configure ("bundling");
13
 
47
 
-
 
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
 
14
# create our configuration, with some defaults
53
# create our configuration, with some defaults
15
# these are overridden by command line stuff
54
# these are overridden by command line stuff
16
my $config = {
55
my $config = {
17
   # the source, where we're coming from
56
   # the source, where we're coming from
18
   'source' => '',
57
   'source' => '',
Line 26... Line 65...
26
   'recurse' => 0,
65
   'recurse' => 0,
27
   # show more information
66
   # show more information
28
   'verbose' => 0
67
   'verbose' => 0
29
   };
68
   };
30
 
69
 
-
 
70
# Parses a dataset string, which may include a server (server:dataset),
-
 
71
# and returns a hashref with 'server' and 'dataset' keys.
31
sub parseDataSet {
72
sub parseDataSet {
32
   my $data = shift;
73
   my $data = shift;
33
   my %return;
74
   my %return;
34
   my ( $server, $dataset ) = split( ':', $data );
75
   my ( $server, $dataset ) = split( ':', $data );
35
   if ( $dataset ) { # they passed a server:dataset
76
   if ( $dataset ) { # they passed a server:dataset
Line 40... Line 81...
40
      $return{'dataset'} = $server;
81
      $return{'dataset'} = $server;
41
   }
82
   }
42
   return \%return;
83
   return \%return;
43
}
84
}
44
 
85
 
-
 
86
# Appends log messages to /tmp/replicate.log.
45
sub logit {
87
sub logit {
46
   open LOG, ">>/tmp/replicate.log" or die "Could not open replicate.log: $!\n";
88
   open LOG, ">>/tmp/replicate.log" or die "Could not open replicate.log: $!\n";
47
   print LOG join( "\n", @_ ) .  "\n";
89
   print LOG join( "\n", @_ ) .  "\n";
48
   close LOG;
90
   close LOG;
49
}
91
}
50
 
92
 
51
# runs a command, redirecting stderr to stdout (which it ignores)
93
# Runs a shell command, capturing stdout and stderr.
52
# then returns 0 and $output on success.
-
 
53
# if error, returns error code and string describing error
94
# Returns (0, output) on success, or (error_code, error_message) on failure.
54
sub run {
95
sub run {
55
   my $command = shift;
96
   my $command = shift;
56
   #&logit( $command );
97
   #&logit( $command );
57
   my $output = qx/$command 2>&1/;
98
   my $output = qx/$command 2>&1/;
58
   if ($? == -1) {
99
   if ($? == -1) {
Line 64... Line 105...
64
      return ($? >> 8, sprintf "child exited with value %d", $? >> 8 ) if $? >> 8;
105
      return ($? >> 8, sprintf "child exited with value %d", $? >> 8 ) if $? >> 8;
65
   }
106
   }
66
   return (0,$output);
107
   return (0,$output);
67
}
108
}
68
 
109
 
69
 
-
 
-
 
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).
70
sub getSnaps {
113
sub getSnaps {
71
   my ($config,$pattern) = @_;
114
   my ($config,$pattern) = @_;
72
   my %return;
115
   my %return;
73
   # actual command to run to get all snapshots, recursively, of the dataset
116
   # actual command to run to get all snapshots, recursively, of the dataset
74
   my $command = 'zfs list -r -t snap ' . $config->{'dataset'};
117
   my $command = 'zfs list -r -t snap ' . $config->{'dataset'};
Line 97... Line 140...
97
      $return{$dataset}{'snaps'}{$snap}{'used'} = $used;
140
      $return{$dataset}{'snaps'}{$snap}{'used'} = $used;
98
   }
141
   }
99
   return \%return;
142
   return \%return;
100
}
143
}
101
 
144
 
102
# get tne number of bytes we will be syncing.
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.
103
sub findSize {
147
sub findSize {
104
   my $config = shift;
148
   my $config = shift;
105
   # check for new snapshots to sync. If they are equal, we are up to date
149
   # check for new snapshots to sync. If they are equal, we are up to date
106
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
150
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
107
      # Build the source command
151
      # Build the source command
Line 136... Line 180...
136
   } else { # nothing to sync
180
   } else { # nothing to sync
137
      return 0;
181
      return 0;
138
   }
182
   }
139
}
183
}
140
 
184
 
141
# create the command necessary to do the replication
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.
142
sub createCommands {
187
sub createCommands {
143
   my $config = shift;
188
   my $config = shift;
144
   # check for new snapshots to sync. If they are equal, we are up to date
189
   # check for new snapshots to sync. If they are equal, we are up to date
145
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
190
   if ( $config->{'source'}->{'lastSnap'} ne $config->{'target'}->{'lastSnap'} ) {
146
      # Build the source command
191
      # Build the source command
Line 179... Line 224...
179
   } else { # source and target are in sync, so do nothing
224
   } else { # source and target are in sync, so do nothing
180
      return '# Nothing new to sync';
225
      return '# Nothing new to sync';
181
   }
226
   }
182
}
227
}
183
   
228
   
184
# find the last snapshot in a hash. The hash is assumed to have a subkey
229
# Finds the most recent snapshot in a hash of snapshots.
185
# 'key'. look for the largest subkey, and return the key for it
230
# Returns the snapshot name with the largest 'key' value.
186
sub getLastSnapshot {
231
sub getLastSnapshot {
187
   my $snapList = shift;
232
   my $snapList = shift;
188
   my $lastKey = 0;
233
   my $lastKey = 0;
189
   my $lastSnap = '';
234
   my $lastSnap = '';
190
   foreach my $snap ( keys %$snapList ) {
235
   foreach my $snap ( keys %$snapList ) {
Line 194... Line 239...
194
      }
239
      }
195
   }
240
   }
196
   return $lastSnap;
241
   return $lastSnap;
197
}
242
}
198
 
243
 
-
 
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
-
 
251
 
-
 
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
}
199
 
273
 
-
 
274
# Calculates the last snapshot for source and target, and checks for consistency.
-
 
275
# Returns (source_last_snap, target_last_snap, warnings_arrayref).
200
sub calculate {
276
sub calculate {
201
   my $config = shift;
277
   my $config = shift;
202
 
278
 
203
   my @warnings;
279
   my @warnings;
204
   
280
   
Line 231... Line 307...
231
   } else {
307
   } else {
232
      return( '','',\@warnings);
308
      return( '','',\@warnings);
233
   }
309
   }
234
} # sub calculate
310
} # sub calculate
235
 
311
 
-
 
312
# Prints usage/help message and exits.
236
sub help {
313
sub help {
237
   use File::Basename;
314
   use File::Basename;
238
   my $me = fileparse( $0 );
315
   my $me = fileparse( $0 );
239
   my $helpMessage = <<"   EOF";
316
   my $helpMessage = <<"   EOF";
240
      $me [flags] [source [target]]
317
      $me [flags] [source [target]]
-
 
318
      Version $VERSION
241
         Syncs source dataset to target dataset
319
         Syncs source dataset to target dataset
242
      
320
      
243
      Parameters (optional)
321
      Parameters (optional)
244
         source - dataset syncing from
322
         source - dataset syncing from
245
         target - dataset syncing to
323
         target - dataset syncing to
Line 250... Line 328...
250
         --filter|f  - Filter (regex) to limit source snapshots to process
328
         --filter|f  - Filter (regex) to limit source snapshots to process
251
         --dryrun|n  - Only displays command(s) to be run
329
         --dryrun|n  - Only displays command(s) to be run
252
         --recurse|r - Process dataset and all child datasets
330
         --recurse|r - Process dataset and all child datasets
253
         --verbose|v - increase verbosity of output
331
         --verbose|v - increase verbosity of output
254
         --bwlimit   - Limit the speed of the connect to # bytes/s. KMGT allowed 
332
         --bwlimit   - Limit the speed of the connect to # bytes/s. KMGT allowed 
-
 
333
         --version|V - display the version number and exit
255
      
334
      
256
      May use short flags with bundling, ie -nrvv is valid for 
335
      May use short flags with bundling, ie -nrvv is valid for 
257
      --dryrun --recurse --verbose --verbose
336
      --dryrun --recurse --verbose --verbose
258
      
337
      
259
      Either source or target must contain a DNS name or IP address of a remote
338
      Either source or target must contain a DNS name or IP address of a remote
Line 292... Line 371...
292
   'filter|f=s',
371
   'filter|f=s',
293
   'dryrun|n',
372
   'dryrun|n',
294
   'recurse|r',
373
   'recurse|r',
295
   'bwlimit=s',
374
   'bwlimit=s',
296
   'verbose|v+',
375
   'verbose|v+',
-
 
376
   'version|V',
297
   'help|h'
377
   'help|h'
298
);
378
);
299
 
379
 
300
&help() if $config->{'help'};
380
&help() if $config->{'help'};
-
 
381
if ($config->{'version'}) {
-
 
382
   print "replicate version $VERSION\n" ;
-
 
383
   exit 0;
-
 
384
}
301
# allow them to use positional, without flags, such as
385
# allow them to use positional, without flags, such as
302
# replicate source target --filter='regex' -n
386
# replicate source target --filter='regex' -n
303
$config->{'source'} = shift unless $config->{'source'};
387
$config->{'source'} = shift unless $config->{'source'};
304
$config->{'target'} = shift unless $config->{'target'};
388
$config->{'target'} = shift unless $config->{'target'};
305
die "You must enter a source and a target, at a minimum\n" unless $config->{'source'} && $config->{'target'};
389
die "You must enter a source and a target, at a minimum\n" unless $config->{'source'} && $config->{'target'};
Line 318... Line 402...
318
 
402
 
319
# connect to servers and get all existing snapshots
403
# connect to servers and get all existing snapshots
320
$config->{'target'}->{'snapshots'} = &getSnaps( $config->{'target'}, $config->{'filter'} );
404
$config->{'target'}->{'snapshots'} = &getSnaps( $config->{'target'}, $config->{'filter'} );
321
$config->{'source'}->{'snapshots'} = &getSnaps( $config->{'source'}, $config->{'filter'} );
405
$config->{'source'}->{'snapshots'} = &getSnaps( $config->{'source'}, $config->{'filter'} );
322
 
406
 
323
# we sync from last snap on target machine to last snap on source machine. calculate simply
407
# Check for child dataset snapshot consistency on source and target
324
# finds the last snapshot on source and target
408
my @source_jobs = check_child_snap_consistency($config, 'source');
325
( $config->{'source'}->{'lastSnap'}, $config->{'target'}->{'lastSnap'} ) = &calculate( $config );
409
my @target_jobs = check_child_snap_consistency($config, 'target');
326
 
410
 
327
# calculate transfer size if they want any feedback at all. Since this does take a few seconds
411
# If either side has inconsistencies, break into per-dataset jobs
-
 
412
my %all_jobs;
-
 
413
$all_jobs{$_}++ for (@source_jobs, @target_jobs);
-
 
414
 
-
 
415
foreach my $dataset (sort keys %all_jobs) {
328
# to calculate, we won't run it unless they want a report
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} } };
329
$config->{'report'}->{'Bytes Transferred'} = &findSize( $config ) if $config->{'verbose'};
419
    $job_config{'target'} = { %{$config->{'target'}}, 'dataset' => $config->{'target'}{'dataset'} . $dataset, 'snapshots' => { $dataset => $config->{'target'}{'snapshots'}{$dataset} } };
330
 
420
 
-
 
421
    ( $job_config{'source'}{'lastSnap'}, $job_config{'target'}{'lastSnap'} ) = &calculate( \%job_config );
331
# actually creates the commands to do the replicate
422
    $job_config{'report'}{'Bytes Transferred'} = &findSize( \%job_config ) if $config->{'verbose'};
332
my $commands = &createCommands( $config );
423
    my $commands = &createCommands( \%job_config );
333
print "$commands\n" if $config->{'verbose'} > 1 or $config->{'dryrun'};
424
    print "$commands\n" if $config->{'verbose'} > 1 or $config->{'dryrun'};
334
if ( $config->{'dryrun'} ) {
425
    if ( $config->{'dryrun'} ) {
335
   print "Dry Run\n";
426
        print "Dry Run for $dataset\n";
336
} else {
427
    } else {
337
   print qx/$commands/ if $commands =~ m/^[a-zA-Z]/;
428
        print qx/$commands/ if $commands =~ m/^[a-zA-Z]/;
-
 
429
    }
338
}
430
}
339
 
431
 
340
$config->{'report'}->{'End Time'} = time;
432
$config->{'report'}->{'End Time'} = time;
341
$config->{'report'}->{'Elapsed Time'} = $config->{'report'}->{'End Time'} - $config->{'report'}->{'Start Time'};
433
$config->{'report'}->{'Elapsed Time'} = $config->{'report'}->{'End Time'} - $config->{'report'}->{'Start Time'};
342
if ( $config->{'verbose'}  ) {
434
if ( $config->{'verbose'}  ) {