Subversion Repositories zfs_utils

Rev

Rev 51 | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 51 Rev 60
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: