| Line 33... |
Line 33... |
| 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.
|
| 35 |
# Requires a configuration file in YAML format next to the script.
|
35 |
# Requires a configuration file in YAML format next to the script.
|
| 36 |
# Author: R. W. Rodlico <rodo@dailydata.net>
|
36 |
# Author: R. W. Rodlico <rodo@dailydata.net>
|
| 37 |
# Created: December 2025
|
37 |
# Created: December 2025
|
| - |
|
38 |
#
|
| 38 |
# Revision History:
|
39 |
# Revision History:
|
| 39 |
# Version: 0.1 2025-12-10 Initial version
|
40 |
# Version: 0.1 RWR 2025-12-10
|
| - |
|
41 |
# Development version
|
| - |
|
42 |
#
|
| - |
|
43 |
# Version: 1.0 RWR 2025-12-15
|
| - |
|
44 |
# Tested and ready for initial release
|
| - |
|
45 |
|
| 40 |
|
46 |
|
| 41 |
use strict;
|
47 |
use strict;
|
| 42 |
use warnings;
|
48 |
use warnings;
|
| 43 |
|
49 |
|
| 44 |
our $VERSION = '0.1';
|
50 |
our $VERSION = '1.0';
|
| 45 |
|
51 |
|
| 46 |
use File::Basename;
|
52 |
use File::Basename;
|
| 47 |
use FindBin;
|
53 |
use FindBin;
|
| 48 |
use lib "$FindBin::Bin/..";
|
54 |
use lib "$FindBin::Bin/..";
|
| 49 |
use Data::Dumper;
|
55 |
use Data::Dumper;
|
| Line 144... |
Line 150... |
| 144 |
'dataset' => 'files_share',
|
150 |
'dataset' => 'files_share',
|
| 145 |
},
|
151 |
},
|
| 146 |
}
|
152 |
}
|
| 147 |
};
|
153 |
};
|
| 148 |
|
154 |
|
| 149 |
# read the status file and return as list. If the file doesn't exits, returns an empty list
|
155 |
## Read the status file and return its lines as an ARRAYREF.
|
| - |
|
156 |
##
|
| - |
|
157 |
## Arguments:
|
| - |
|
158 |
## $filename - path to the status file (string)
|
| - |
|
159 |
##
|
| - |
|
160 |
## Behavior:
|
| - |
|
161 |
## - If the file exists and is readable, reads all lines, chomps newlines and returns an ARRAYREF
|
| - |
|
162 |
## containing the lines (each line generally holds a fully qualified snapshot name).
|
| - |
|
163 |
## - If the file does not exist or cannot be opened, logs a message and returns an empty ARRAYREF.
|
| - |
|
164 |
##
|
| - |
|
165 |
## Returns: ARRAYREF of lines (possibly empty).
|
| 150 |
sub getStatusFile {
|
166 |
sub getStatusFile {
|
| 151 |
my $filename = shift;
|
167 |
my $filename = shift;
|
| 152 |
# read in history/status file
|
168 |
# read in history/status file
|
| 153 |
my @lines = ();
|
169 |
my @lines = ();
|
| 154 |
if ( -e $filename && open my $fh, '<', $filename ) {
|
170 |
if ( -e $filename && open my $fh, '<', $filename ) {
|
| Line 159... |
Line 175... |
| 159 |
logMsg("Error: could not read status file '$filename', assuming a fresh start: $!");
|
175 |
logMsg("Error: could not read status file '$filename', assuming a fresh start: $!");
|
| 160 |
}
|
176 |
}
|
| 161 |
return \@lines;
|
177 |
return \@lines;
|
| 162 |
}
|
178 |
}
|
| 163 |
|
179 |
|
| 164 |
# write the status list to file
|
180 |
## Write the status list to disk safely.
|
| - |
|
181 |
##
|
| - |
|
182 |
## Arguments:
|
| - |
|
183 |
## $filename - path to the status file to write
|
| - |
|
184 |
## $statusList - ARRAYREF of lines to write into the file
|
| - |
|
185 |
##
|
| - |
|
186 |
## Behavior:
|
| - |
|
187 |
## - If an existing file is present, renames it to `$filename.bak` as a simple backup.
|
| - |
|
188 |
## - Writes the provided lines to `$filename` (one per line).
|
| - |
|
189 |
## - Logs the written contents. Dies on failure to backup or write the file.
|
| 165 |
sub writeStatusFile {
|
190 |
sub writeStatusFile {
|
| 166 |
my ( $filename, $statusList ) = @_;
|
191 |
my ( $filename, $statusList ) = @_;
|
| 167 |
# backup existing status file
|
192 |
# backup existing status file
|
| 168 |
if ( -e $filename ) {
|
193 |
if ( -e $filename ) {
|
| 169 |
rename( $filename, "$filename.bak" ) or do {
|
194 |
rename( $filename, "$filename.bak" ) or do {
|
| Line 182... |
Line 207... |
| 182 |
logMsg("Error: could not write status file '$filename': $!");
|
207 |
logMsg("Error: could not write status file '$filename': $!");
|
| 183 |
die;
|
208 |
die;
|
| 184 |
}
|
209 |
}
|
| 185 |
}
|
210 |
}
|
| 186 |
|
211 |
|
| 187 |
# simple sub to take root/dataset/datset/dataset and turn it into
|
212 |
## Convert a path-like dataset name into a filename-safe string.
|
| - |
|
213 |
##
|
| - |
|
214 |
## Examples:
|
| - |
|
215 |
## 'pool/fs/sub' => 'pool.fs.sub' (default)
|
| - |
|
216 |
##
|
| - |
|
217 |
## Arguments:
|
| 188 |
# dataset.dataset.dataset
|
218 |
## $string - input string to convert
|
| - |
|
219 |
## $delimiter - input delimiter to split on (default: '/')
|
| - |
|
220 |
## $substitution - output separator to join with (default: '.')
|
| - |
|
221 |
##
|
| - |
|
222 |
## Returns: a joined string suitable for use as a filename.
|
| 189 |
sub dirnameToFileName {
|
223 |
sub dirnameToFileName {
|
| 190 |
my ( $string, $delimiter, $substitution ) = @_;
|
224 |
my ( $string, $delimiter, $substitution ) = @_;
|
| 191 |
$delimiter //= '/';
|
225 |
$delimiter //= '/';
|
| 192 |
$substitution //= '.';
|
226 |
$substitution //= '.';
|
| 193 |
my @parts = split( /\Q$delimiter\E/, $string );
|
227 |
my @parts = split( /\Q$delimiter\E/, $string );
|
| 194 |
return join( $substitution, @parts );
|
228 |
return join( $substitution, @parts );
|
| 195 |
}
|
229 |
}
|
| 196 |
|
230 |
|
| 197 |
# perform replication on source server
|
231 |
## Perform replication for all configured datasets on the source server.
|
| - |
|
232 |
##
|
| 198 |
# $config - configuration hashref
|
233 |
## Arguments:
|
| - |
|
234 |
## $config - configuration HASHREF (loaded from YAML). Must contain `datasets` and `transport` entries.
|
| 199 |
# $statusList - list of last snapshots replicated for each dataset in previous replications
|
235 |
## $statusList - ARRAYREF of previously replicated snapshot full names (used to compute incremental sends)
|
| - |
|
236 |
##
|
| - |
|
237 |
## Behavior:
|
| 200 |
# return new status list after replication containing updated last snapshots
|
238 |
## - Iterates over datasets defined in `$config->{datasets}`.
|
| 201 |
# this script will actually replicate the datasets to the sneakernet disk
|
239 |
## - For each dataset, enumerates available snapshots on the source and calls
|
| - |
|
240 |
## `makeReplicateCommands` to produce zfs send commands.
|
| - |
|
241 |
## - 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).
|
| - |
|
243 |
## - Respects `$config->{dryrun}`: no commands are executed when dryrun is enabled.
|
| - |
|
244 |
##
|
| - |
|
245 |
## Returns: ARRAYREF `$newStatus` containing updated status lines (latest snapshots per dataset).
|
| 202 |
sub doSourceReplication {
|
246 |
sub doSourceReplication {
|
| 203 |
my ($config, $statusList) = @_;
|
247 |
my ($config, $statusList) = @_;
|
| 204 |
my $newStatus = [];
|
248 |
my $newStatus = [];
|
| 205 |
foreach my $dataset ( sort keys %{$config->{datasets}} ) {
|
249 |
foreach my $dataset ( sort keys %{$config->{datasets}} ) {
|
| 206 |
logMsg("Processing dataset '$dataset'");
|
250 |
logMsg("Processing dataset '$dataset'");
|
| 207 |
# get list of all snapshots on dataset
|
251 |
# get list of all snapshots on dataset
|
| 208 |
my $sourceList;
|
252 |
my $sourceList;
|
| 209 |
# print Dumper( $config ) . "\n";
|
- |
|
| 210 |
# print "$dataset\n";
|
- |
|
| 211 |
# print Dumper( $config->{datasets}->{$dataset} ) . "\n";
|
- |
|
| 212 |
# die;
|
- |
|
| 213 |
if ( -e "$scriptDirectory/test.status") {
|
253 |
if ( -e "$scriptDirectory/test.status") {
|
| 214 |
$sourceList = getStatusFile( "$scriptDirectory/test.status" );
|
254 |
$sourceList = getStatusFile( "$scriptDirectory/test.status" );
|
| 215 |
} else {
|
255 |
} else {
|
| 216 |
$sourceList = [ runCmd( "zfs list -rt snap -H -o name $config->{datasets}->{$dataset}->{source}" ) ];
|
256 |
$sourceList = [ runCmd( "zfs list -rt snap -H -o name $config->{datasets}->{$dataset}->{source}" ) ];
|
| 217 |
}
|
257 |
}
|
| Line 227... |
Line 267... |
| 227 |
);
|
267 |
);
|
| 228 |
if ( %$commands ) {
|
268 |
if ( %$commands ) {
|
| 229 |
foreach my $cmd ( keys %$commands ) {
|
269 |
foreach my $cmd ( keys %$commands ) {
|
| 230 |
my $command = $commands->{$cmd};
|
270 |
my $command = $commands->{$cmd};
|
| 231 |
my $outputFile = $cmd;
|
271 |
my $outputFile = $cmd;
|
| 232 |
$outputFile = replaceSlashWithDot($outputFile);
|
272 |
my $outfile = dirnameToFileName( $cmd );
|
| 233 |
$command .= " | openssl enc -aes-256-cbc -K $config->{transport}->{encryption}->{key} -iv $config->{transport}->{encryption}->{IV} " if $config->{transport}->{encryption}->{key};
|
273 |
$command .= " | openssl enc -aes-256-cbc -K $config->{transport}->{encryption}->{key} -iv $config->{transport}->{encryption}->{IV} " if $config->{transport}->{encryption}->{key};
|
| 234 |
$command .= " > $config->{transport}->{mount_point}/" . dirnameToFileName( $cmd );
|
274 |
$command .= " > $config->{transport}->{mount_point}/$outfile";
|
| 235 |
logMsg("Running command: $command");
|
275 |
logMsg("Running command: $command");
|
| 236 |
runCmd( $command ) unless $config->{dryrun};
|
276 |
runCmd( $command ) unless $config->{dryrun};
|
| 237 |
}
|
277 |
}
|
| 238 |
} else {
|
278 |
} else {
|
| 239 |
logMsg( "Nothing to do for $dataset" );
|
279 |
logMsg( "Nothing to do for $dataset" );
|
| 240 |
}
|
280 |
}
|
| 241 |
}
|
281 |
}
|
| 242 |
return $newStatus;
|
282 |
return $newStatus;
|
| 243 |
}
|
283 |
}
|
| 244 |
|
284 |
|
| 245 |
# perform cleanup actions
|
285 |
## Perform cleanup and final reporting after replication.
|
| - |
|
286 |
##
|
| - |
|
287 |
## Arguments:
|
| 246 |
# $config - configuration hashref
|
288 |
## $config - configuration HASHREF (required)
|
| 247 |
# $message - optional message to include in the report
|
289 |
## $message - OPTIONAL message to include in the report
|
| 248 |
#
|
290 |
##
|
| - |
|
291 |
## Behavior:
|
| - |
|
292 |
## - Logs disk usage for the transport mount and zpool list for diagnostics.
|
| - |
|
293 |
## - Ensures the report subject and message are populated, then attempts to unmount
|
| - |
|
294 |
## the transport drive and send the report (via `sendReport`).
|
| - |
|
295 |
## - If `shutdown_after_replication` is set in the running role's config, attempts
|
| - |
|
296 |
## to shut down the machine (honors `$config->{dryrun}`).
|
| 249 |
sub cleanup{
|
297 |
sub cleanup{
|
| 250 |
my ( $config, $message ) = @_;
|
298 |
my ( $config, $message ) = @_;
|
| 251 |
# add disk space utilization information on transport to the log
|
299 |
# add disk space utilization information on transport to the log
|
| 252 |
logMsg( "Disk space utilization on transport disk:\n" . runCmd( "df -h $config->{transport}->{mount_point}" ) . "\n" );
|
300 |
logMsg( "Disk space utilization on transport disk:\n" . runCmd( "df -h $config->{transport}->{mount_point}" ) . "\n" );
|
| 253 |
# add information about the server (zpools) to the log
|
301 |
# add information about the server (zpools) to the log
|
| Line 264... |
Line 312... |
| 264 |
logMsg( "Shutting down target server as per configuration" );
|
312 |
logMsg( "Shutting down target server as per configuration" );
|
| 265 |
runCmd( "shutdown -p now" ) unless $config->{dryrun};
|
313 |
runCmd( "shutdown -p now" ) unless $config->{dryrun};
|
| 266 |
}
|
314 |
}
|
| 267 |
}
|
315 |
}
|
| 268 |
|
316 |
|
| 269 |
# update the target datasets from the files on the transport drive
|
317 |
## Update the target zfs datasets from files on the transport drive.
|
| - |
|
318 |
##
|
| - |
|
319 |
## Arguments:
|
| - |
|
320 |
## $config - configuration HASHREF containing `transport` and `datasets` entries.
|
| - |
|
321 |
##
|
| - |
|
322 |
## Behavior:
|
| - |
|
323 |
## - Reads all regular files from the transport mount point (via `getDirectoryList`).
|
| - |
|
324 |
## - For each file, determines the intended target dataset based on the filename and
|
| - |
|
325 |
## the `datasets` mapping in the config, optionally decrypts via `openssl enc -d`,
|
| - |
|
326 |
## and pipes the stream into `zfs receive -F` to update the target dataset.
|
| - |
|
327 |
## - Uses `runCmd` to execute the receive commands and logs the executed command string.
|
| 270 |
sub updateTarget {
|
328 |
sub updateTarget {
|
| 271 |
my $config = shift;
|
329 |
my $config = shift;
|
| 272 |
my $files = getDirectoryList( $config->{transport}->{mount_point});
|
330 |
my $files = getDirectoryList( $config->{transport}->{mount_point});
|
| 273 |
foreach my $filename ( @$files ) {
|
331 |
foreach my $filename ( @$files ) {
|
| 274 |
my $targetDataset = basename( $filename );
|
332 |
my $targetDataset = basename( $filename );
|