| Line 39... |
Line 39... |
| 39 |
# currently used for sneakernet scripts, but plans to expand to other ZFS related tasks
|
39 |
# currently used for sneakernet scripts, but plans to expand to other ZFS related tasks
|
| 40 |
# functions include:
|
40 |
# functions include:
|
| 41 |
# runCmd: execute a command and return its output (captures exit status in $lastRunError;
|
41 |
# runCmd: execute a command and return its output (captures exit status in $lastRunError;
|
| 42 |
# supports optional stderr merge via $merge_stderr)
|
42 |
# supports optional stderr merge via $merge_stderr)
|
| 43 |
# shredFile: securely delete a file using gshred (note: not effective on ZFS due to COW)
|
43 |
# shredFile: securely delete a file using gshred (note: not effective on ZFS due to COW)
|
| 44 |
# logMsg: timestamped logging to a file and optionally to console
|
44 |
# logMsg: timestamped logging to a file and optionally to console; respects $verboseLoggingLevel
|
| 45 |
# loadConfig: load a YAML configuration file into a hashref; will create the file from a
|
45 |
# loadConfig: load a YAML configuration file into a hashref; will create the file from a
|
| 46 |
# provided default hashref if the file does not exist (uses YAML::XS or YAML::Tiny)
|
46 |
# provided default hashref if the file does not exist (uses YAML::XS or YAML::Tiny)
|
| 47 |
# mountDriveByLabel: find and mount a drive by its GPT label (supports ufs/msdos; waits
|
47 |
# mountDriveByLabel: find and mount a drive by its GPT label (supports ufs/msdos; waits
|
| 48 |
# for device and creates mountpoint)
|
48 |
# for device and creates mountpoint)
|
| 49 |
# unmountDriveByLabel: unmount a drive found by GPT label and remove the mountpoint if empty
|
49 |
# unmountDriveByLabel: unmount a drive found by GPT label and remove the mountpoint if empty
|
| Line 51... |
Line 51... |
| 51 |
# decryptAndMountGeli: attach GELI devices, optionally build a combined key, import the pool
|
51 |
# decryptAndMountGeli: attach GELI devices, optionally build a combined key, import the pool
|
| 52 |
# and mount ZFS datasets
|
52 |
# and mount ZFS datasets
|
| 53 |
# makeGeliKey: create a GELI key by XOR'ing a remote binary keyfile and a local 256-bit hex key;
|
53 |
# makeGeliKey: create a GELI key by XOR'ing a remote binary keyfile and a local 256-bit hex key;
|
| 54 |
# writes a 32-byte binary key file with mode 0600
|
54 |
# writes a 32-byte binary key file with mode 0600
|
| 55 |
# findGeliDisks: discover candidate disks suitable for GELI on the host
|
55 |
# findGeliDisks: discover candidate disks suitable for GELI on the host
|
| 56 |
# makeReplicateCommands: build zfs send/receive command lists from snapshot lists and prior status
|
56 |
# makeReplicateCommands: build zfs send/receive command lists from snapshot lists and prior status;
|
| - |
|
57 |
# intelligently determines recursive vs per-filesystem sends and incremental vs full sends
|
| - |
|
58 |
# based on snapshot availability; filters snapshots by matching parent path + dataset name
|
| - |
|
59 |
# to avoid false matches with similarly-named datasets in different locations
|
| 57 |
# sendReport: helper to deliver replication reports (email/file) — exported for scripts to implement
|
60 |
# sendReport: helper to deliver replication reports (email/file) — exported for scripts to implement
|
| 58 |
# fatalError: helper to log a fatal condition and die (convenience wrapper)
|
61 |
# fatalError: helper to log a fatal condition and die (convenience wrapper)
|
| 59 |
# getDirectoryList: utility to list directory contents with optional filters
|
62 |
# getDirectoryList: utility to list directory contents with optional filters
|
| 60 |
# cleanDirectory: safe directory cleaning utility used by snapshot pruning helpers
|
63 |
# cleanDirectory: safe directory cleaning utility used by snapshot pruning helpers
|
| 61 |
# exported package variables: $logFileName, $displayLogsOnConsole, $lastRunError, $verboseLoggingLevel
|
64 |
# exported package variables: $logFileName, $displayLogsOnConsole, $lastRunError, $verboseLoggingLevel
|
| Line 63... |
Line 66... |
| 63 |
# v1.0 RWR 20251215
|
66 |
# v1.0 RWR 20251215
|
| 64 |
# This is the initial, tested release
|
67 |
# This is the initial, tested release
|
| 65 |
#
|
68 |
#
|
| 66 |
# v1.0.1 RWR 20251215
|
69 |
# v1.0.1 RWR 20251215
|
| 67 |
# Added verbose logging control to logMsg calls, controlled by $verboseLoggingLevel
|
70 |
# Added verbose logging control to logMsg calls, controlled by $verboseLoggingLevel
|
| - |
|
71 |
#
|
| - |
|
72 |
# v1.1.0 RWR 20251217
|
| - |
|
73 |
# Added added version variable $VERSION
|
| - |
|
74 |
# Added more logging of source/target snapshots in doSourceReplication for debugging.
|
| - |
|
75 |
# max verbosity is now 5 instead of 3.
|
| - |
|
76 |
# optimized makeReplicateCommands to avoid unnecessary copies of large arrays and increase reliability
|
| - |
|
77 |
# when processing snapshots with inconsistent naming in child datasets.
|
| 68 |
|
78 |
|
| 69 |
# Exported functions and variables
|
79 |
# Exported functions and variables
|
| 70 |
|
80 |
|
| 71 |
our @EXPORT_OK = qw(loadConfig shredFile mountDriveByLabel unmountDriveByLabel mountGeli logMsg runCmd makeReplicateCommands sendReport fatalError getDirectoryList cleanDirectory $logFileName $displayLogsOnConsole $lastRunError $verboseLoggingLevel);
|
81 |
our @EXPORT_OK = qw(loadConfig shredFile mountDriveByLabel unmountDriveByLabel mountGeli logMsg runCmd makeReplicateCommands sendReport fatalError getDirectoryList cleanDirectory $logFileName $displayLogsOnConsole $lastRunError $verboseLoggingLevel);
|
| 72 |
|
82 |
|
| 73 |
our $VERSION = '1.0';
|
83 |
our $VERSION = '1.1.0';
|
| 74 |
|
84 |
|
| 75 |
# these are variables which affect the flow of the program and are exported so they can be modified by the caller
|
85 |
# these are variables which affect the flow of the program and are exported so they can be modified by the caller
|
| 76 |
our $logFileName = '/tmp/zfs_utils.log'; # this can be overridden by the caller, and turned off with empty string
|
86 |
our $logFileName = '/tmp/zfs_utils.log'; # this can be overridden by the caller, and turned off with empty string
|
| 77 |
our $displayLogsOnConsole = 1; # if non-zero, log messages are also printed to console
|
87 |
our $displayLogsOnConsole = 1; # if non-zero, log messages are also printed to console
|
| 78 |
our $merge_stderr = 0; # if set to 1, stderr is captured in runCmd
|
88 |
our $merge_stderr = 0; # if set to 1, stderr is captured in runCmd
|
| Line 557... |
Line 567... |
| 557 |
# returns hashref of commands to execute, of form
|
567 |
# returns hashref of commands to execute, of form
|
| 558 |
# {$dataset} = "zfs send command"
|
568 |
# {$dataset} = "zfs send command"
|
| 559 |
# where $dataset above can be a child of $dataset
|
569 |
# where $dataset above can be a child of $dataset
|
| 560 |
sub makeReplicateCommands {
|
570 |
sub makeReplicateCommands {
|
| 561 |
my ( $sourceSnapsRef, $targetSnapsRef, $dataset, $sourceParent, $targetParent, $newStatusRef ) = @_;
|
571 |
my ( $sourceSnapsRef, $targetSnapsRef, $dataset, $sourceParent, $targetParent, $newStatusRef ) = @_;
|
| - |
|
572 |
|
| - |
|
573 |
# Ensure all array refs are defined (use empty arrays if not provided)
|
| 562 |
$sourceSnapsRef ||= [];
|
574 |
$sourceSnapsRef ||= [];
|
| 563 |
$targetSnapsRef ||= [];
|
575 |
$targetSnapsRef ||= [];
|
| 564 |
$newStatusRef ||= [];
|
576 |
$newStatusRef ||= [];
|
| - |
|
577 |
|
| - |
|
578 |
# Normalize parent paths: ensure they end with '/' unless empty
|
| - |
|
579 |
# This makes path construction consistent later (e.g., "pool/" + "dataset")
|
| 565 |
$sourceParent //= '';
|
580 |
$sourceParent //= '';
|
| 566 |
$sourceParent .= '/' unless $sourceParent eq '' or substr($sourceParent, -1) eq '/';
|
581 |
$sourceParent .= '/' unless $sourceParent eq '' or substr($sourceParent, -1) eq '/';
|
| 567 |
$targetParent //= '';
|
582 |
$targetParent //= '';
|
| 568 |
$targetParent .= '/' unless $targetParent eq '' or substr($targetParent, -1) eq '/';
|
583 |
$targetParent .= '/' unless $targetParent eq '' or substr($targetParent, -1) eq '/';
|
| 569 |
|
584 |
|
| 570 |
my %commands; # this will hold the commands (and the dataset as key) for return
|
- |
|
| 571 |
|
585 |
|
| 572 |
fatalError( "No dataset defined in makeReplicateCommands, can not continue") unless $dataset;
|
586 |
logMsg( "makeReplicateCommands: dataset=[$dataset] sourceParent=[$sourceParent] targetParent=[$targetParent]" ) if $verboseLoggingLevel >= 4;
|
| 573 |
|
- |
|
| 574 |
# filter only the target and source snapshots which have this dataset in them, then remove
|
587 |
logMsg( "makeReplicateCommands: source snapshots count=" . scalar(@$sourceSnapsRef) . ", target snapshots count=" . scalar(@$targetSnapsRef) ) if $verboseLoggingLevel >= 4;
|
| 575 |
# the parent of each.
|
588 |
if ($verboseLoggingLevel >= 5) {
|
| 576 |
my $targetSnaps = [ map{ s/^$targetParent//r } grep{ /$dataset/ } @$targetSnapsRef ];
|
589 |
logMsg( "makeReplicateCommands: RAW target snapshots BEFORE filtering:" );
|
| 577 |
my $sourceSnaps = [ map{ s/^$sourceParent//r } grep{ /$dataset/ } @$sourceSnapsRef ];
|
590 |
foreach my $snap (@$targetSnapsRef) {
|
| - |
|
591 |
logMsg( " [$snap]" );
|
| 578 |
|
592 |
}
|
| 579 |
#print "Dataset => [$dataset]\nSource Parent => [$sourceParent]\nTarget Parent => [$targetParent]\n";
|
593 |
logMsg( "makeReplicateCommands: RAW source snapshots BEFORE filtering:" );
|
| - |
|
594 |
foreach my $snap (@$sourceSnapsRef) {
|
| - |
|
595 |
logMsg( " [$snap]" );
|
| - |
|
596 |
}
|
| - |
|
597 |
}
|
| - |
|
598 |
|
| 580 |
#print "Source Snaps\n" . Dumper( $sourceSnapsRef) . "\nTarget Snaps\n" . Dumper( $targetSnapsRef) . "\n";
|
599 |
my %commands; # Hash to store generated zfs send commands, keyed by filesystem name
|
| 581 |
|
600 |
|
| 582 |
#print Dumper( $targetSnaps ) . "\n" . Dumper( $sourceSnaps ) . "\n"; die;
|
601 |
fatalError( "No dataset defined in makeReplicateCommands, can not continue") unless $dataset;
|
| 583 |
#return \%commands;
|
- |
|
| 584 |
|
602 |
|
| 585 |
# parse snapshots: each line is expected to have snapshot fullname as first token: pool/fs@snap ...
|
603 |
# Filter snapshot lists to only include snapshots matching our dataset and its children
|
| - |
|
604 |
# The dataset should match as a full path component (not substring)
|
| - |
|
605 |
# Then strip the parent path prefix from each snapshot name
|
| - |
|
606 |
# Example: "storage/mydata@snap1" becomes "mydata@snap1" when sourceParent="storage/"
|
| - |
|
607 |
# This allows us to work with relative paths and handle different parent paths on source/target
|
| - |
|
608 |
# Match: storage/mydata@snap, storage/mydata/child@snap
|
| - |
|
609 |
# Don't match: storage/mydataset@snap (if dataset is "mydata")
|
| - |
|
610 |
# Don't match: storage/otherparent/mydata@snap (different parent path)
|
| - |
|
611 |
my $targetSnaps = [ map{ s/^$targetParent//r } grep{ /^\Q$targetParent$dataset\E(?:\/|@)/ } @$targetSnapsRef ];
|
| - |
|
612 |
my $sourceSnaps = [ map{ s/^$sourceParent//r } grep{ /^\Q$sourceParent$dataset\E(?:\/|@)/ } @$sourceSnapsRef ];
|
| - |
|
613 |
|
| - |
|
614 |
logMsg( "makeReplicateCommands: filtered source snapshots count=" . scalar(@$sourceSnaps) . ", filtered target snapshots count=" . scalar(@$targetSnaps) ) if $verboseLoggingLevel >= 4;
|
| - |
|
615 |
logMsg( "makeReplicateCommands: filtered source snapshots: " . join(', ', sort @$sourceSnaps) ) if $verboseLoggingLevel >= 5;
|
| - |
|
616 |
logMsg( "makeReplicateCommands: filtered target snapshots: " . join(', ', sort @$targetSnaps) ) if $verboseLoggingLevel >= 5;
|
| - |
|
617 |
|
| - |
|
618 |
# Parse source snapshots to build a hash indexed by filesystem
|
| - |
|
619 |
# Input lines may have format: "pool/fs@snapshot extra data"
|
| - |
|
620 |
# We extract just the first token (pool/fs@snapshot) and split it into filesystem and snapshot name
|
| - |
|
621 |
# Result: %snaps_by_fs = { "pool/fs" => ["snap1", "snap2", ...] }
|
| - |
|
622 |
# This groups all snapshots by their parent filesystem
|
| 586 |
my %snaps_by_fs;
|
623 |
my %snaps_by_fs;
|
| 587 |
foreach my $line (@$sourceSnaps) {
|
624 |
foreach my $line (@$sourceSnaps) {
|
| 588 |
next unless defined $line && $line =~ /\S/;
|
625 |
next unless defined $line && $line =~ /\S/; # Skip empty lines
|
| 589 |
my ($tok) = split /\s+/, $line;
|
626 |
my ($tok) = split /\s+/, $line; # Get first token
|
| 590 |
next unless $tok && $tok =~ /@/;
|
627 |
next unless $tok && $tok =~ /@/; # Must contain @ separator
|
| 591 |
my ($fs, $snap) = split /@/, $tok, 2;
|
628 |
my ($fs, $snap) = split /@/, $tok, 2; # Split into filesystem and snapshot name
|
| 592 |
push @{ $snaps_by_fs{$fs} }, $snap;
|
629 |
push @{ $snaps_by_fs{$fs} }, $snap; # Add snapshot to this filesystem's list
|
| 593 |
}
|
630 |
}
|
| 594 |
|
631 |
|
| - |
|
632 |
logMsg( "makeReplicateCommands: parsed filesystems: " . join(', ', sort keys %snaps_by_fs) ) if $verboseLoggingLevel >= 4;
|
| - |
|
633 |
|
| 595 |
# nothing to do
|
634 |
# If no snapshots were found, return empty array (nothing to replicate)
|
| 596 |
return [] unless keys %snaps_by_fs;
|
635 |
return [] unless keys %snaps_by_fs;
|
| 597 |
|
636 |
|
| - |
|
637 |
# Determine the root filesystem for recursive operations
|
| 598 |
# figure root filesystem: first snapshot line's fs is the requested root
|
638 |
# We try to get it from the first non-empty snapshot line, otherwise use first sorted key
|
| - |
|
639 |
# The root filesystem is used when we can do a single recursive send instead of multiple sends
|
| 599 |
my ($first_line) = grep { defined $_ && $_ =~ /\S/ } @$sourceSnaps;
|
640 |
my ($first_line) = grep { defined $_ && $_ =~ /\S/ } @$sourceSnaps;
|
| 600 |
my ($root_fs) = $first_line ? (split(/\s+/, $first_line))[0] =~ /@/ ? (split(/@/, (split(/\s+/, $first_line))[0]))[0] : undef : undef;
|
641 |
my ($root_fs) = $first_line ? (split(/\s+/, $first_line))[0] =~ /@/ ? (split(/@/, (split(/\s+/, $first_line))[0]))[0] : undef : undef;
|
| 601 |
$root_fs ||= (sort keys %snaps_by_fs)[0];
|
642 |
$root_fs ||= (sort keys %snaps_by_fs)[0];
|
| 602 |
|
643 |
|
| - |
|
644 |
# Build a hash of the most recent snapshot on target for each filesystem
|
| - |
|
645 |
# This tells us what's already been replicated, so we can do incremental sends
|
| - |
|
646 |
# If a filesystem isn't in this hash, we need to do a full (non-incremental) send
|
| 603 |
# helper: find last status entry for a filesystem (status lines contain full snapshot names pool/fs@snap)
|
647 |
# Note: If multiple snapshots exist for a filesystem, we keep only the last one
|
| - |
|
648 |
# (later entries override earlier ones in the hash assignment)
|
| 604 |
my %last_status_for;
|
649 |
my %last_status_for;
|
| 605 |
for my $s (@$targetSnaps) {
|
650 |
for my $s (@$targetSnaps) {
|
| 606 |
next unless $s && $s =~ /@/;
|
651 |
next unless $s && $s =~ /@/;
|
| 607 |
my ($fs, $snap) = split /@/, $s, 2;
|
652 |
my ($fs, $snap) = split /@/, $s, 2;
|
| 608 |
$last_status_for{$fs} = $snap; # later entries override earlier ones -> last occurrence kept
|
653 |
$last_status_for{$fs} = $snap; # later entries override earlier ones -> last occurrence kept
|
| 609 |
}
|
654 |
}
|
| 610 |
|
655 |
|
| - |
|
656 |
if ($verboseLoggingLevel >= 4) {
|
| - |
|
657 |
logMsg( "makeReplicateCommands: last status snapshots:" );
|
| 611 |
# build per-filesystem "from" and "to"
|
658 |
for my $fs (sort keys %last_status_for) {
|
| - |
|
659 |
logMsg( " $fs => $last_status_for{$fs}" );
|
| - |
|
660 |
}
|
| - |
|
661 |
}
|
| - |
|
662 |
|
| - |
|
663 |
# Build "from" and "to" snapshot mappings for each filesystem
|
| - |
|
664 |
# "to" = the newest snapshot on source (what we want to send)
|
| - |
|
665 |
# "from" = the last replicated snapshot on target (what we're sending from)
|
| - |
|
666 |
# If "from" is undef, this filesystem hasn't been replicated before -> full send needed
|
| - |
|
667 |
# Example: from="daily-2025-12-15" to="daily-2025-12-17" -> incremental send
|
| - |
|
668 |
# from=undef to="daily-2025-12-17" -> full send
|
| - |
|
669 |
# NOTE: The "from" snapshot must exist in the source's snapshot list for incremental send
|
| - |
|
670 |
# If it doesn't exist in source, we need to find a common snapshot or do full send
|
| 612 |
my %from_for;
|
671 |
my %from_for;
|
| 613 |
my %to_for;
|
672 |
my %to_for;
|
| 614 |
foreach my $fs (keys %snaps_by_fs) {
|
673 |
foreach my $fs (keys %snaps_by_fs) {
|
| 615 |
my $arr = $snaps_by_fs{$fs};
|
674 |
my $arr = $snaps_by_fs{$fs}; # Get all snapshots for this filesystem
|
| 616 |
next unless @$arr;
|
675 |
next unless @$arr; # Skip if no snapshots
|
| - |
|
676 |
$to_for{$fs} = $arr->[-1]; # Last element = newest snapshot to send
|
| - |
|
677 |
|
| - |
|
678 |
# Check if the target's last status snapshot exists in the source list
|
| - |
|
679 |
# If it does, we can do incremental send from that point
|
| - |
|
680 |
# If it doesn't, the target may have a snapshot the source doesn't have anymore
|
| 617 |
$to_for{$fs} = $arr->[-1];
|
681 |
my $target_last = $last_status_for{$fs};
|
| - |
|
682 |
if (defined $target_last && grep { $_ eq $target_last } @$arr) {
|
| 618 |
$from_for{$fs} = $last_status_for{$fs}; # may be undef -> full send required
|
683 |
$from_for{$fs} = $target_last; # Use target's last snapshot as "from"
|
| - |
|
684 |
} else {
|
| - |
|
685 |
# Target's snapshot doesn't exist in source list - need full send
|
| - |
|
686 |
$from_for{$fs} = undef;
|
| - |
|
687 |
}
|
| 619 |
}
|
688 |
}
|
| 620 |
|
689 |
|
| - |
|
690 |
if ($verboseLoggingLevel >= 4) {
|
| - |
|
691 |
logMsg( "makeReplicateCommands: from/to mapping:" );
|
| - |
|
692 |
for my $fs (sort keys %to_for) {
|
| - |
|
693 |
my $from = $from_for{$fs} // '(none - full send)';
|
| - |
|
694 |
my $send_type = defined $from_for{$fs} ? 'incremental' : 'full';
|
| - |
|
695 |
logMsg( " $fs: from=$from to=$to_for{$fs} [$send_type]" );
|
| - |
|
696 |
}
|
| - |
|
697 |
}
|
| - |
|
698 |
|
| 621 |
# decide if we can do a single recursive send:
|
699 |
# Optimization check: Can we do a single recursive send?
|
| - |
|
700 |
# Recursive sends are more efficient when replicating entire filesystem hierarchies
|
| 622 |
# condition: all 'to' snapshot names are identical
|
701 |
# Condition: all filesystems must be sending to the same-named snapshot
|
| - |
|
702 |
# Example: If pool/data@daily-2025-12-17 and pool/data/child@daily-2025-12-17 exist,
|
| - |
|
703 |
# we can do "zfs send -R pool/data@daily-2025-12-17" instead of two separate sends
|
| 623 |
my %to_names = map { $_ => 1 } values %to_for;
|
704 |
my %to_names = map { $_ => 1 } values %to_for; # Get unique "to" snapshot names
|
| 624 |
my $single_to_name = (keys %to_names == 1) ? (keys %to_names)[0] : undef;
|
705 |
my $single_to_name = (keys %to_names == 1) ? (keys %to_names)[0] : undef;
|
| - |
|
706 |
|
| - |
|
707 |
logMsg( "makeReplicateCommands: single_to_name=" . ($single_to_name // '(none - varied snapshots)') ) if $verboseLoggingLevel >= 4;
|
| 625 |
|
708 |
|
| 626 |
if ($single_to_name) {
|
709 |
if ($single_to_name) {
|
| 627 |
# check whether any from is missing
|
710 |
# All filesystems are targeting the same snapshot name
|
| - |
|
711 |
# Now check if we can use incremental recursive send or need full send
|
| 628 |
my @from_values = map { $from_for{$_} } sort keys %from_for;
|
712 |
my @from_values = map { $from_for{$_} } sort keys %from_for;
|
| 629 |
my $any_from_missing = grep { !defined $_ } @from_values;
|
713 |
my $any_from_missing = grep { !defined $_ } @from_values; # Any filesystem not yet replicated?
|
| 630 |
my %from_names = map { $_ => 1 } grep { defined $_ } @from_values;
|
714 |
my %from_names = map { $_ => 1 } grep { defined $_ } @from_values; # Unique "from" names
|
| 631 |
my $single_from_name = (keys %from_names == 1) ? (keys %from_names)[0] : undef;
|
715 |
my $single_from_name = (keys %from_names == 1) ? (keys %from_names)[0] : undef;
|
| 632 |
|
716 |
|
| - |
|
717 |
logMsg( "makeReplicateCommands: single_from_name=" . ($single_from_name // '(none)') . ", any_from_missing=$any_from_missing" ) if $verboseLoggingLevel >= 4;
|
| - |
|
718 |
|
| 633 |
if ($any_from_missing) {
|
719 |
if ($any_from_missing) {
|
| - |
|
720 |
# At least one filesystem has never been replicated (from=undef)
|
| - |
|
721 |
# Check if the ROOT filesystem has been replicated - if not, must do full recursive send
|
| - |
|
722 |
# If only children are missing, we can still do per-filesystem sends with incrementals where possible
|
| 634 |
# full recursive send from root
|
723 |
if (!defined $from_for{$root_fs}) {
|
| - |
|
724 |
# Root filesystem has never been replicated - must do full recursive send
|
| - |
|
725 |
# Command: zfs send -R pool/dataset@snapshot
|
| - |
|
726 |
logMsg( "makeReplicateCommands: generating full recursive send (root filesystem has no prior snapshot)" ) if $verboseLoggingLevel >= 4;
|
| 635 |
$commands{$root_fs} = sprintf('zfs send -R %s%s@%s', $sourceParent, $root_fs, $single_to_name);
|
727 |
$commands{$root_fs} = sprintf('zfs send -R %s%s@%s', $sourceParent, $root_fs, $single_to_name);
|
| - |
|
728 |
} else {
|
| - |
|
729 |
# Root has been replicated, but some children haven't - do per-filesystem sends
|
| - |
|
730 |
# This allows incremental sends for filesystems that have prior snapshots
|
| - |
|
731 |
logMsg( "makeReplicateCommands: root replicated but some children missing - using per-filesystem sends" ) if $verboseLoggingLevel >= 4;
|
| - |
|
732 |
foreach my $fs (sort keys %to_for) {
|
| - |
|
733 |
my $to = $to_for{$fs};
|
| - |
|
734 |
my $from = $from_for{$fs};
|
| - |
|
735 |
if ($from) {
|
| - |
|
736 |
# Incremental send for this filesystem
|
| - |
|
737 |
$commands{$fs} = sprintf('zfs send -I %s%s@%s %s%s@%s', $sourceParent, $fs, $from, $sourceParent, $fs, $to)
|
| - |
|
738 |
unless $from eq $to;
|
| - |
|
739 |
} else {
|
| - |
|
740 |
# Full send for this filesystem (never replicated before)
|
| - |
|
741 |
logMsg( "makeReplicateCommands: $fs - full send (no prior snapshot)" ) if $verboseLoggingLevel >= 4;
|
| - |
|
742 |
$commands{$fs} = sprintf('zfs send %s%s@%s', $sourceParent, $fs, $to);
|
| - |
|
743 |
}
|
| - |
|
744 |
}
|
| - |
|
745 |
}
|
| 636 |
}
|
746 |
}
|
| 637 |
elsif ($single_from_name) {
|
747 |
elsif ($single_from_name) {
|
| - |
|
748 |
# All filesystems have been replicated AND they all have the same "from" snapshot
|
| - |
|
749 |
# Perfect case for incremental recursive send
|
| - |
|
750 |
# Command: zfs send -R -I pool/dataset@old pool/dataset@new
|
| - |
|
751 |
if ($single_from_name eq $single_to_name) {
|
| 638 |
# incremental recursive send, but don't do it if they are the same
|
752 |
# Source and target are already identical - nothing to send
|
| - |
|
753 |
logMsg( "makeReplicateCommands: from and to snapshots are identical ($single_from_name) - no send needed" ) if $verboseLoggingLevel >= 4;
|
| - |
|
754 |
} else {
|
| - |
|
755 |
logMsg( "makeReplicateCommands: generating incremental recursive send from $single_from_name to $single_to_name" ) if $verboseLoggingLevel >= 4;
|
| 639 |
$commands{$root_fs} = sprintf('zfs send -R -I %s%s@%s %s%s@%s',
|
756 |
$commands{$root_fs} = sprintf('zfs send -R -I %s%s@%s %s%s@%s',
|
| 640 |
$sourceParent, $root_fs, $single_from_name, $sourceParent, $root_fs, $single_to_name)
|
757 |
$sourceParent, $root_fs, $single_from_name, $sourceParent, $root_fs, $single_to_name);
|
| 641 |
unless $single_from_name eq $single_to_name;
|
758 |
}
|
| 642 |
}
|
759 |
}
|
| 643 |
else {
|
760 |
else {
|
| 644 |
# from snapshots differ across children -> fall back to per-filesystem sends
|
761 |
# Filesystems have different "from" snapshots - can't use single recursive send
|
| - |
|
762 |
# Fall back to individual per-filesystem sends
|
| - |
|
763 |
logMsg( "makeReplicateCommands: from snapshots differ across children - using per-filesystem sends" ) if $verboseLoggingLevel >= 4;
|
| 645 |
foreach my $fs (sort keys %to_for) {
|
764 |
foreach my $fs (sort keys %to_for) {
|
| 646 |
my $to = $to_for{$fs};
|
765 |
my $to = $to_for{$fs};
|
| 647 |
my $from = $from_for{$fs};
|
766 |
my $from = $from_for{$fs};
|
| 648 |
if ($from) {
|
767 |
if ($from) {
|
| - |
|
768 |
# Incremental send: send all intermediate snapshots from "from" to "to"
|
| 649 |
# if from and to are different, add it
|
769 |
# Skip if from and to are identical (already up to date)
|
| 650 |
$commands{$fs} = sprintf('zfs send -I %s%s@%s %s%s@%s', $sourceParent, $fs, $from, $sourceParent, $fs, $to)
|
770 |
$commands{$fs} = sprintf('zfs send -I %s%s@%s %s%s@%s', $sourceParent, $fs, $from, $sourceParent, $fs, $to)
|
| 651 |
unless $from eq $to;
|
771 |
unless $from eq $to;
|
| 652 |
} else {
|
772 |
} else {
|
| - |
|
773 |
# Full send: no prior snapshot on target, send everything
|
| 653 |
$commands{$fs} = sprintf('zfs send %s%s@%s', $sourceParent, $fs, $to);
|
774 |
$commands{$fs} = sprintf('zfs send %s%s@%s', $sourceParent, $fs, $to);
|
| 654 |
}
|
775 |
}
|
| 655 |
}
|
776 |
}
|
| 656 |
}
|
777 |
}
|
| 657 |
|
778 |
|
| 658 |
# update new status: record newest snap for every filesystem
|
779 |
# Update the status array with the new target snapshots
|
| - |
|
780 |
# This will be written to the status file for tracking what's been replicated
|
| - |
|
781 |
# Format: targetParent/filesystem@snapshot
|
| 659 |
foreach my $fs (keys %to_for) {
|
782 |
foreach my $fs (keys %to_for) {
|
| 660 |
push @$newStatusRef, sprintf('%s%s@%s', $targetParent, $fs, $to_for{$fs});
|
783 |
push @$newStatusRef, sprintf('%s%s@%s', $targetParent, $fs, $to_for{$fs});
|
| 661 |
}
|
784 |
}
|
| - |
|
785 |
logMsg( "makeReplicateCommands: added " . scalar(keys %to_for) . " entries to new status" ) if $verboseLoggingLevel >= 4;
|
| 662 |
} else {
|
786 |
} else {
|
| 663 |
# not all children share same newest snap -> per-filesystem sends
|
787 |
# Filesystems have different "to" snapshot names - can't use recursive send
|
| - |
|
788 |
# Must send each filesystem individually
|
| - |
|
789 |
# This handles cases like:
|
| - |
|
790 |
# - Parent: pool/data@daily-2025-12-17
|
| - |
|
791 |
# - Child: pool/data/child@hourly-2025-12-17-14
|
| - |
|
792 |
# Each filesystem can still do incremental sends to its own target snapshot
|
| - |
|
793 |
logMsg( "makeReplicateCommands: varied 'to' snapshots - using per-filesystem sends (each may be incremental)" ) if $verboseLoggingLevel >= 4;
|
| 664 |
foreach my $fs (sort keys %to_for) {
|
794 |
foreach my $fs (sort keys %to_for) {
|
| 665 |
my $to = $to_for{$fs};
|
795 |
my $to = $to_for{$fs};
|
| 666 |
my $from = $from_for{$fs};
|
796 |
my $from = $from_for{$fs};
|
| 667 |
if ($from) {
|
797 |
if ($from) {
|
| - |
|
798 |
# Incremental send for this filesystem to its specific target snapshot
|
| - |
|
799 |
# Command: zfs send -I pool/fs@old pool/fs@new
|
| - |
|
800 |
# Note: "old" and "new" are specific to this filesystem, not necessarily matching parent
|
| - |
|
801 |
logMsg( "makeReplicateCommands: $fs - incremental send from $from to $to" ) if $verboseLoggingLevel >= 4;
|
| 668 |
$commands{$fs} = sprintf('zfs send -I %s%s@%s %s%s@%s', $sourceParent, $fs, $from, $sourceParent, $fs, $to);
|
802 |
$commands{$fs} = sprintf('zfs send -I %s%s@%s %s%s@%s', $sourceParent, $fs, $from, $sourceParent, $fs, $to);
|
| 669 |
} else {
|
803 |
} else {
|
| - |
|
804 |
# Full send for this filesystem (never replicated before, or target snap not in source)
|
| - |
|
805 |
# Command: zfs send pool/fs@snapshot
|
| - |
|
806 |
logMsg( "makeReplicateCommands: $fs - full send to $to (no common snapshot)" ) if $verboseLoggingLevel >= 4;
|
| 670 |
$commands{$fs} = sprintf('zfs send %s%s@%s', $sourceParent, $fs, $to);
|
807 |
$commands{$fs} = sprintf('zfs send %s%s@%s', $sourceParent, $fs, $to);
|
| 671 |
}
|
808 |
}
|
| - |
|
809 |
# Add to status tracking
|
| 672 |
push @$newStatusRef, sprintf('%s%s@%s', $targetParent, $fs, $to);
|
810 |
push @$newStatusRef, sprintf('%s%s@%s', $targetParent, $fs, $to);
|
| 673 |
}
|
811 |
}
|
| - |
|
812 |
logMsg( "makeReplicateCommands: added " . scalar(keys %to_for) . " entries to new status" ) if $verboseLoggingLevel >= 4;
|
| 674 |
}
|
813 |
}
|
| 675 |
|
814 |
|
| - |
|
815 |
logMsg( "makeReplicateCommands: generated " . scalar(keys %commands) . " commands" ) if $verboseLoggingLevel >= 4;
|
| - |
|
816 |
|
| 676 |
# return arrayref of commands (caller can iterate or join with pipes)
|
817 |
# Return hash reference of commands: { "filesystem" => "zfs send command" }
|
| - |
|
818 |
# Caller will typically pipe these to "zfs receive" on target
|
| 677 |
return \%commands;
|
819 |
return \%commands;
|
| 678 |
}
|
820 |
}
|
| 679 |
|
821 |
|
| 680 |
# Send report via email and/or copy to target drive.
|
822 |
# Send report via email and/or copy to target drive.
|
| 681 |
# $reportConfig is a hashref with optional keys:
|
823 |
# $reportConfig is a hashref with optional keys:
|