| 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'} ) {
|