| Line 24... |
Line 24... |
| 24 |
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
24 |
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
| 25 |
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
25 |
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
| 26 |
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
26 |
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
| 27 |
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
27 |
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
| 28 |
|
28 |
|
| 29 |
# sneakernet.pl
|
29 |
# sneakernet
|
| 30 |
# Script to perform sneakernet replication of ZFS datasets between two servers
|
30 |
# Script to perform sneakernet replication of ZFS datasets between two servers
|
| 31 |
# using an external transport drive.
|
31 |
# using an external transport drive.
|
| 32 |
# Uses ZFS send/receive to replicate datasets to/from the transport drive.
|
32 |
# Uses ZFS send/receive to replicate datasets to/from the transport drive.
|
| 33 |
# Optionally uses symmetric encryption to encrypt datasets during transport.
|
33 |
# Optionally uses symmetric encryption to encrypt datasets during transport.
|
| 34 |
# On the target server, can optionally use GELI to encrypt the datasets on disk.
|
34 |
# On the target server, can optionally use GELI to encrypt the datasets on disk.
|
| Line 43... |
Line 43... |
| 43 |
# Version: 1.0 RWR 2025-12-15
|
43 |
# Version: 1.0 RWR 2025-12-15
|
| 44 |
# Tested and ready for initial release
|
44 |
# Tested and ready for initial release
|
| 45 |
#
|
45 |
#
|
| 46 |
# Version: 1.0.1 RWR 2025-12-15
|
46 |
# Version: 1.0.1 RWR 2025-12-15
|
| 47 |
# Added verbose logging control to logMsg calls, controlled by ZFS_Utils::$verboseLoggingLevel
|
47 |
# Added verbose logging control to logMsg calls, controlled by ZFS_Utils::$verboseLoggingLevel
|
| - |
|
48 |
#
|
| - |
|
49 |
# Version: 1.1 RWR 2025-12-17
|
| - |
|
50 |
# Added filtering so only the snapshots for the current dataset are considered when
|
| - |
|
51 |
# generating replication commands.
|
| - |
|
52 |
# added test.source.status (in script directory) file for dry-run testing of source replication.
|
| - |
|
53 |
# changed default name of status file to sneakernet_target.status to avoid confusion.
|
| - |
|
54 |
# added more logging of source/target snapshots in doSourceReplication for debugging.
|
| - |
|
55 |
#
|
| - |
|
56 |
# Version: 1.1.1 RWR 2025-12-18
|
| - |
|
57 |
# Fixed makeReplicateCommands call to pass actual dataset name from config rather than config key.
|
| - |
|
58 |
# Updated documentation to clarify how makeReplicateCommands filters snapshots by parent+dataset path
|
| - |
|
59 |
# to avoid false matches with similarly-named datasets in different locations.
|
| 48 |
|
60 |
|
| 49 |
|
61 |
|
| 50 |
use strict;
|
62 |
use strict;
|
| 51 |
use warnings;
|
63 |
use warnings;
|
| 52 |
|
64 |
|
| 53 |
our $VERSION = '1.0.1';
|
65 |
our $VERSION = '1.1.1';
|
| 54 |
|
66 |
|
| 55 |
use File::Basename;
|
67 |
use File::Basename;
|
| 56 |
use FindBin;
|
68 |
use FindBin;
|
| 57 |
use lib "$FindBin::Bin/..";
|
69 |
use lib "$FindBin::Bin/..";
|
| 58 |
use Data::Dumper;
|
70 |
use Data::Dumper;
|
| Line 69... |
Line 81... |
| 69 |
my $configFileName = "$scriptFullPath.conf.yaml";
|
81 |
my $configFileName = "$scriptFullPath.conf.yaml";
|
| 70 |
|
82 |
|
| 71 |
my $config = {
|
83 |
my $config = {
|
| 72 |
'dryrun' => 0,
|
84 |
'dryrun' => 0,
|
| 73 |
'verbosity' => 1,
|
85 |
'verbosity' => 1,
|
| 74 |
# file created on source server to track last copyed dataset
|
86 |
# file created on source server to track last copied dataset, assuming
|
| - |
|
87 |
# the import goes well on the target server
|
| 75 |
'status_file' => "$scriptFullPath.status",
|
88 |
'status_file' => "$scriptDirectory/sneakernet_target.status",
|
| 76 |
'log_file' => "$scriptFullPath.log",
|
89 |
'log_file' => "$scriptFullPath.log",
|
| 77 |
#information about source server
|
90 |
#information about source server
|
| 78 |
'source' => {
|
91 |
'source' => {
|
| 79 |
'hostname' => '', # used to see if we are on source
|
92 |
'hostname' => '', # used to see if we are on source
|
| 80 |
'poolname' => 'pool', # name of the ZFS pool to export
|
93 |
'poolname' => 'pool', # name of the ZFS pool to export
|
| Line 235... |
Line 248... |
| 235 |
## $statusList - ARRAYREF of previously replicated snapshot full names (used to compute incremental sends)
|
248 |
## $statusList - ARRAYREF of previously replicated snapshot full names (used to compute incremental sends)
|
| 236 |
##
|
249 |
##
|
| 237 |
## Behavior:
|
250 |
## Behavior:
|
| 238 |
## - Iterates over datasets defined in `$config->{datasets}`.
|
251 |
## - Iterates over datasets defined in `$config->{datasets}`.
|
| 239 |
## - For each dataset, enumerates available snapshots on the source and calls
|
252 |
## - For each dataset, enumerates available snapshots on the source and calls
|
| 240 |
## `makeReplicateCommands` to produce zfs send commands.
|
253 |
## `makeReplicateCommands` (from ZFS_Utils) to produce zfs send commands.
|
| - |
|
254 |
## - `makeReplicateCommands` filters snapshots by matching parent+dataset path (e.g., ^pool/dataset(?:/|@))
|
| - |
|
255 |
## to avoid false matches with similarly-named datasets, and intelligently determines whether to use
|
| - |
|
256 |
## recursive vs per-filesystem sends and incremental vs full sends based on snapshot availability.
|
| 241 |
## - Commands are optionally piped through `openssl enc` when `transport.encryption.key` is set.
|
257 |
## - Commands are optionally piped through `openssl enc` when `transport.encryption.key` is set.
|
| 242 |
## - The output is written to files on the transport mount point (one file per dataset snapshot set).
|
258 |
## - The output is written to files on the transport mount point (one file per dataset snapshot set).
|
| 243 |
## - Respects `$config->{dryrun}`: no commands are executed when dryrun is enabled.
|
259 |
## - Respects `$config->{dryrun}`: no commands are executed when dryrun is enabled.
|
| 244 |
##
|
260 |
##
|
| 245 |
## Returns: ARRAYREF `$newStatus` containing updated status lines (latest snapshots per dataset).
|
261 |
## Returns: ARRAYREF `$newStatus` containing updated status lines (latest snapshots per dataset).
|
| 246 |
sub doSourceReplication {
|
262 |
sub doSourceReplication {
|
| 247 |
my ($config, $statusList) = @_;
|
263 |
my ($config, $targetList) = @_;
|
| 248 |
my $newStatus = [];
|
264 |
my $newStatus = [];
|
| 249 |
foreach my $dataset ( sort keys %{$config->{datasets}} ) {
|
265 |
foreach my $dataset ( sort keys %{$config->{datasets}} ) {
|
| 250 |
logMsg("Processing dataset '$dataset'") if $verboseLoggingLevel >= 1;
|
266 |
logMsg("Processing dataset '$dataset'") if $verboseLoggingLevel >= 1;
|
| 251 |
# get list of all snapshots on dataset
|
267 |
# get list of all snapshots on dataset
|
| 252 |
my $sourceList;
|
268 |
my $sourceList;
|
| 253 |
if ( -e "$scriptDirectory/test.status") {
|
269 |
if ( -e "$scriptDirectory/test.source.status" && $config->{dryrun} ) {
|
| - |
|
270 |
logMsg("Using test.source.status file for source snapshots in dry-run mode") if $verboseLoggingLevel >= 2;
|
| 254 |
$sourceList = getStatusFile( "$scriptDirectory/test.status" );
|
271 |
$sourceList = getStatusFile( "$scriptDirectory/test.source.status" );
|
| 255 |
} else {
|
272 |
} else {
|
| 256 |
$sourceList = [ runCmd( "zfs list -rt snap -H -o name $config->{datasets}->{$dataset}->{source}" ) ];
|
273 |
$sourceList = [ runCmd( "zfs list -rt snap -H -o name $config->{datasets}->{$dataset}->{source}/$config->{datasets}->{$dataset}->{dataset}" ) ];
|
| - |
|
274 |
}
|
| - |
|
275 |
|
| - |
|
276 |
if ($verboseLoggingLevel >= 5) {
|
| - |
|
277 |
logMsg("Source snapshots for dataset '$dataset': " . join( ', ', sort @$sourceList ) );
|
| - |
|
278 |
logMsg("Target snapshots for dataset '$dataset': " . join( ', ', sort @$targetList ) );
|
| 257 |
}
|
279 |
}
|
| 258 |
|
- |
|
| 259 |
# process dataset here
|
280 |
# process dataset here using makeReplicateCommands from ZFS_Utils
|
| - |
|
281 |
# $dataset here is the config key; the actual dataset name is in $config->{datasets}->{$dataset}->{dataset}
|
| 260 |
my $commands = makeReplicateCommands(
|
282 |
my $commands = makeReplicateCommands(
|
| 261 |
$sourceList,
|
283 |
$sourceList,
|
| 262 |
$statusList,
|
284 |
$targetList,
|
| 263 |
$dataset,
|
285 |
$config->{datasets}->{$dataset}->{dataset},
|
| 264 |
$config->{datasets}->{$dataset}->{source},
|
286 |
$config->{datasets}->{$dataset}->{source},
|
| 265 |
$config->{datasets}->{$dataset}->{target},
|
287 |
$config->{datasets}->{$dataset}->{target},
|
| 266 |
$newStatus
|
288 |
$newStatus
|
| 267 |
);
|
289 |
);
|
| 268 |
if ( %$commands ) {
|
290 |
if ( %$commands ) {
|
| Line 296... |
Line 318... |
| 296 |
## to shut down the machine (honors `$config->{dryrun}`).
|
318 |
## to shut down the machine (honors `$config->{dryrun}`).
|
| 297 |
sub cleanup{
|
319 |
sub cleanup{
|
| 298 |
my ( $config, $message ) = @_;
|
320 |
my ( $config, $message ) = @_;
|
| 299 |
# add disk space utilization information on transport to the log
|
321 |
# add disk space utilization information on transport to the log
|
| 300 |
logMsg( "Disk space utilization on transport disk:\n" . runCmd( "df -h $config->{transport}->{mount_point}" ) . "\n" )
|
322 |
logMsg( "Disk space utilization on transport disk:\n" . runCmd( "df -h $config->{transport}->{mount_point}" ) . "\n" )
|
| 301 |
if $verboseLoggingLevel >= 1;
|
323 |
if -d $config->{transport}->{mount_point} && $verboseLoggingLevel >= 1;
|
| 302 |
# add information about the server (zpools) to the log
|
324 |
# add information about the server (zpools) to the log
|
| 303 |
my $servername = `hostname -s`;
|
325 |
my $servername = `hostname -s`;
|
| 304 |
chomp $servername;
|
326 |
chomp $servername;
|
| 305 |
logMsg( "Zpools on server $servername:\n" . join( "\n", runCmd( "zpool list" ) ) . "\n" ) if $verboseLoggingLevel >= 1;
|
327 |
logMsg( "Zpools on server $servername:\n" . join( "\n", runCmd( "zpool list" ) ) . "\n" ) if `which zpool` && $verboseLoggingLevel >= 1;
|
| 306 |
$config->{$config->{runningAs}}->{report}->{subject} //= "Replication Report for $config->{runningAs} server $servername";
|
328 |
$config->{$config->{runningAs}}->{report}->{subject} //= "Replication Report for $config->{runningAs} server $servername";
|
| 307 |
$message //= "Replication completed on $config->{runningAs} server $servername.";
|
329 |
$message //= "Replication completed on $config->{runningAs} server $servername.";
|
| 308 |
# unmount the sneakernet drive
|
330 |
# unmount the sneakernet drive
|
| 309 |
unmountDriveByLabel( $config->{transport} ) unless $config->{dryrun};
|
331 |
unmountDriveByLabel( $config->{transport} ) unless $config->{dryrun};
|
| 310 |
sendReport( $config->{$config->{runningAs}}->{report}, $message, $config->{log_file} );
|
332 |
sendReport( $config->{$config->{runningAs}}->{report}, $message, $config->{log_file} );
|
| Line 386... |
Line 408... |
| 386 |
$config->{runningAs} = $servername eq $config->{source}->{hostname} ? 'source' :
|
408 |
$config->{runningAs} = $servername eq $config->{source}->{hostname} ? 'source' :
|
| 387 |
$servername eq $config->{target}->{hostname} ? 'target' : 'unknown';
|
409 |
$servername eq $config->{target}->{hostname} ? 'target' : 'unknown';
|
| 388 |
|
410 |
|
| 389 |
# mount the transport drive, fatal error if we can not find it
|
411 |
# mount the transport drive, fatal error if we can not find it
|
| 390 |
fatalError( "Unable to mount tranport drive with label $config->{transport}->{label}", $config, \&cleanup )
|
412 |
fatalError( "Unable to mount tranport drive with label $config->{transport}->{label}", $config, \&cleanup )
|
| 391 |
unless $config->{transport}->{mount_point} = mountDriveByLabel( $config->{transport} );
|
413 |
unless $config->{dryrun} or $config->{transport}->{mount_point} = mountDriveByLabel( $config->{transport} );
|
| 392 |
|
414 |
|
| 393 |
# main program logic
|
415 |
# main program logic
|
| 394 |
if ( $config->{runningAs} eq 'source' ) {
|
416 |
if ( $config->{runningAs} eq 'source' ) {
|
| 395 |
logMsg "Running as source server" if $verboseLoggingLevel >= 1;
|
417 |
logMsg "Running as source server" if $verboseLoggingLevel >= 1;
|
| 396 |
# remove all files from transport disk, but leave all subdirectories alone
|
418 |
# remove all files from transport disk, but leave all subdirectories alone
|