#!/usr/bin/env perl ## # backupLV # # Poor man's LVM2 backup helper: create a snapshot of an LV and stream it into # a compressed image file in a target backup directory, with optional retention # cleanup for older images. # # Copyright (c) 2026, Daily Data, Inc. All rights reserved. # Licensed under the FreeBSD Simplified (2-Clause) License. # See https://opensource.org/license/bsd-2-clause for full terms. # # Revision History: # 2026-08-04 rodolico v1.0 Refactored for command safety, strict LV # whitelist validation, and configurable xz # compression defaults. # 2026-08-04 rodolico v1.1 Added dry-run summary output and quiet mode # for cron-friendly operation. # 2026-08-04 rodolico v1.2 Added multi-LV processing via repeated -lv and # optional -lv-list-file input. # 2026-08-04 rodolico v1.3 Added -default-vg support for LV lists that # contain LV names without vg prefixes. # 2026-08-04 rodolico v1.4 Improved CLI validation errors for missing # required parameters. # 2026-08-04 rodolico v1.5 Added short and long CLI flag aliases for all # primary options. # 2026-08-04 rodolico v1.6 Set Getopt::Long to case-sensitive mode so -t # (target) does not conflict with -T (xz-threads). use strict; use warnings; use File::Path qw(make_path); use Getopt::Long; Getopt::Long::Configure('no_ignore_case'); my $snapshotCreated = 0; my $snapshotPathForEndHandler = ''; my $isQuiet = 0; END { cleanupSnapshotBestEffort($snapshotPathForEndHandler) if $snapshotCreated; } main(); ## # Main program flow: parse options, optionally clean old images, snapshot, # stream to xz image, and remove the snapshot for each requested LV. # # @return void sub main { my %args = parseArguments(); $isQuiet = $args{quiet}; # Ensure the output directory exists before any LVM operation begins. make_path($args{targetDir}) unless -d $args{targetDir}; for my $lvPath (@{$args{lvPaths}}) { backupOneLv(%args, lvPath => $lvPath); } print "Done. Processed " . scalar(@{$args{lvPaths}}) . " logical volume(s).\n" unless $args{quiet}; } ## # Parse and validate command line arguments. # # @return hash Parsed configuration values. sub parseArguments { my @lvPaths; my $lvListFile; my $defaultVg; my $targetDir; my $snapshotSize = '10G'; my $keepTimeDays; my $xzLevel = 9; my $xzThreads; my $xzCpuPercent = 50; my $dryRun; my $quiet; my $help; GetOptions( 'lv|l=s@' => \@lvPaths, 'lv-list-file|L=s' => \$lvListFile, 'default-vg|g=s' => \$defaultVg, 'target|t=s' => \$targetDir, 'snap-size|s=s' => \$snapshotSize, 'keeptime|k=i' => \$keepTimeDays, 'xz-level|z=i' => \$xzLevel, 'xz-threads|T=i' => \$xzThreads, 'xz-cpu-percent|p=i' => \$xzCpuPercent, 'dry-run|n' => \$dryRun, 'quiet|q' => \$quiet, 'help|h' => \$help, ) or die usage(); die usage() if $help; die "Missing required -target\n" . usage() unless defined $targetDir; $targetDir =~ s{/$}{}; die "target directory cannot be empty\n" if $targetDir eq ''; if (defined $defaultVg) { validateLvToken($defaultVg, 'default-vg'); } if (defined $lvListFile) { my @fileLvPaths = readLvListFile($lvListFile); push @lvPaths, @fileLvPaths; } die "Provide at least one -lv or -lv-list-file\n" . usage() unless @lvPaths; # Normalize and validate every LV input once at startup. my @validatedLvPaths; for my $lvPath (@lvPaths) { my ($vg, $lvName) = parseAndValidateLvPath($lvPath, $defaultVg); push @validatedLvPaths, "$vg/$lvName"; } # Deduplicate while preserving first-seen order. my %seen; @validatedLvPaths = grep { !$seen{$_}++ } @validatedLvPaths; die "keeptime must be >= 0\n" if defined $keepTimeDays && $keepTimeDays < 0; die "xz-level must be between 0 and 9\n" if $xzLevel < 0 || $xzLevel > 9; die "xz-cpu-percent must be between 1 and 100\n" if $xzCpuPercent < 1 || $xzCpuPercent > 100; die "xz-threads must be >= 1\n" if defined $xzThreads && $xzThreads < 1; if (!defined $xzThreads) { my $cpuCount = getCpuCount(); $xzThreads = int(($cpuCount * $xzCpuPercent) / 100); $xzThreads = 1 if $xzThreads < 1; } print "Compression: xz level=$xzLevel, threads=$xzThreads\n" unless $quiet; print "Processing " . scalar(@validatedLvPaths) . " logical volume(s).\n" unless $quiet; return ( lvPaths => \@validatedLvPaths, targetDir => $targetDir, snapshotSize => $snapshotSize, keepTimeDays => $keepTimeDays, xzLevel => $xzLevel, xzThreads => $xzThreads, dryRun => $dryRun ? 1 : 0, quiet => $quiet ? 1 : 0, ); } ## # Backup one logical volume path with optional cleanup and summary output. # # @param hash %args Parsed global command options plus lvPath. # @return void sub backupOneLv { my %args = @_; my ($vg, $lvName) = split('/', $args{lvPath}, 2); my $timestamp = time(); my $snapshotName = "snap_export_" . $timestamp; my $sourcePath = "/dev/$vg/$lvName"; my $snapshotPath = "/dev/$vg/$snapshotName"; my $cleanupPattern = "${vg}_${lvName}_snapshot_*.img.xz"; my $outputFile = sprintf( '%s/%s_%s_snapshot_%d.img.xz', $args{targetDir}, $vg, $lvName, $timestamp, ); $snapshotPathForEndHandler = $snapshotPath; cleanupOldBackups( $args{targetDir}, $vg, $lvName, $args{keepTimeDays}, $args{dryRun}, ); -e $sourcePath or die "Source LV not found: $sourcePath\n"; # Snapshot lifecycle is guarded by END for best-effort removal on failure. runCmdMaybe($args{dryRun}, 'lvcreate', '-s', '-n', $snapshotName, '-L', $args{snapshotSize}, $sourcePath); $snapshotCreated = 1; streamSnapshotToImage( $snapshotPath, $outputFile, $args{xzLevel}, $args{xzThreads}, $args{dryRun}, ); runCmdMaybe($args{dryRun}, 'lvremove', '-y', $snapshotPath); $snapshotCreated = 0; $snapshotPathForEndHandler = ''; if ($args{dryRun}) { printRunSummary( mode => 'dry-run', lv => "$vg/$lvName", snapshotName => $snapshotName, outputFile => $outputFile, cleanupPattern => $cleanupPattern, keepTimeDays => $args{keepTimeDays}, xzLevel => $args{xzLevel}, xzThreads => $args{xzThreads}, quiet => $args{quiet}, ); print "Dry-run complete for $vg/$lvName. No snapshot or backup file was created.\n" unless $args{quiet}; return; } printRunSummary( mode => 'success', lv => "$vg/$lvName", snapshotName => $snapshotName, outputFile => $outputFile, cleanupPattern => $cleanupPattern, keepTimeDays => $args{keepTimeDays}, xzLevel => $args{xzLevel}, xzThreads => $args{xzThreads}, quiet => $args{quiet}, ); print "Done. Image: $outputFile\n" unless $args{quiet}; } ## # Parse and validate vg/lv input. # # @param string $lvPath LV path in vg/lv format, or lv name if default vg set. # @param string|undef $defaultVg Default volume group used for bare lv names. # @return list Two scalars: vg and lv name. sub parseAndValidateLvPath { my ($lvPath, $defaultVg) = @_; my ($vg, $lvName); if ($lvPath =~ m{/}) { ($vg, $lvName) = split('/', $lvPath, 2); die "Invalid LV path: use exactly vg/lv\n" if !defined $vg || !defined $lvName || $vg eq '' || $lvName eq ''; } else { die "LV '$lvPath' is missing vg prefix; provide vg/lv or use -default-vg\n" unless defined $defaultVg && $defaultVg ne ''; $vg = $defaultVg; $lvName = $lvPath; } validateLvToken($vg, 'vg'); validateLvToken($lvName, 'lv'); return ($vg, $lvName); } ## # Read LV paths from a file, ignoring blank lines and # comments. # # @param string $filePath Path to list file. # @return list LV paths in vg/lv format. sub readLvListFile { my ($filePath) = @_; open(my $listHandle, '<', $filePath) or die "Unable to open LV list file '$filePath': $!\n"; my @lvPaths; while (my $line = <$listHandle>) { chomp $line; $line =~ s/^\s+//; $line =~ s/\s+$//; next if $line eq ''; next if $line =~ /^#/; push @lvPaths, $line; } close $listHandle; return @lvPaths; } ## # Enforce an allowlist for VG/LV tokens used in device paths and filenames. # # @param string $token VG or LV token. # @param string $label Human-readable field name for errors. # @return void sub validateLvToken { my ($token, $label) = @_; if ($token !~ /\A[A-Za-z0-9+_.-]+\z/) { die "Invalid $label '$token': only [A-Za-z0-9+_.-] are allowed\n"; } } ## # Run an external command without invoking a shell. # # @param list @cmd Command and arguments. # @return void sub runCmd { my @cmd = @_; print 'Running: ' . join(' ', map { quoteForLog($_) } @cmd) . "\n" unless $isQuiet; system(@cmd) == 0 or die "Command failed (exit=$?): @cmd\n"; } ## # Run an external command or only print it when dry-run mode is enabled. # # @param bool $dryRun True to skip execution. # @param list @cmd Command and arguments. # @return void sub runCmdMaybe { my ($dryRun, @cmd) = @_; if ($dryRun) { print 'Dry-run: ' . join(' ', map { quoteForLog($_) } @cmd) . "\n" unless $isQuiet; return; } runCmd(@cmd); } ## # Log-safe quoting helper for human-readable command output. # # @param string $value Value to quote. # @return string Single-quoted display-safe value. sub quoteForLog { my ($value) = @_; $value =~ s/'/'"'"'/g; return "'$value'"; } ## # Best-effort snapshot cleanup used by END handler. # # @param string $snapshotPath Full snapshot device path. # @return void sub cleanupSnapshotBestEffort { my ($snapshotPath) = @_; return unless $snapshotPath; return unless -e $snapshotPath; system('lvremove', '-y', $snapshotPath); } ## # Remove old backup files for the specific VG/LV according to keeptime. # # @param string $targetDir Directory containing backup images. # @param string $vg Volume group name. # @param string $lvName Logical volume name. # @param int|undef $keepTimeDays Days to retain; undef skips cleanup. # @return void sub cleanupOldBackups { my ($targetDir, $vg, $lvName, $keepTimeDays, $dryRun) = @_; return unless defined $keepTimeDays; my $namePattern = "${vg}_${lvName}_snapshot_*.img.xz"; print "Cleaning up old backups older than $keepTimeDays days in $targetDir...\n" unless $isQuiet; runCmdMaybe( $dryRun, 'find', $targetDir, '-type', 'f', '-name', $namePattern, '-mtime', "+$keepTimeDays", '-delete', ); } ## # Stream a snapshot through xz to produce a compressed image without shell use. # # @param string $snapshotPath Full snapshot device path. # @param string $outputFile Destination image path. # @param int $xzLevel xz compression level (0-9). # @param int $xzThreads xz thread count. # @return void sub streamSnapshotToImage { my ($snapshotPath, $outputFile, $xzLevel, $xzThreads, $dryRun) = @_; my $ddStatusArg = $isQuiet ? 'status=none' : 'status=progress'; if ($dryRun) { print 'Dry-run: ' . join(' ', map { quoteForLog($_) } ('dd', "if=$snapshotPath", 'bs=16M', $ddStatusArg)) . "\n" unless $isQuiet; print 'Dry-run: ' . join(' ', map { quoteForLog($_) } ('xz', '-z', "-$xzLevel", "--threads=$xzThreads", '>', $outputFile)) . "\n" unless $isQuiet; return; } pipe(my $ddRead, my $ddWrite) or die "Unable to create pipe: $!\n"; my $ddPid = fork(); die "Unable to fork dd process: $!\n" unless defined $ddPid; if ($ddPid == 0) { close $ddRead; open(STDOUT, '>&', $ddWrite) or die "Unable to redirect dd stdout: $!\n"; close $ddWrite; exec('dd', "if=$snapshotPath", 'bs=16M', $ddStatusArg); die "Unable to exec dd: $!\n"; } my $xzPid = fork(); die "Unable to fork xz process: $!\n" unless defined $xzPid; if ($xzPid == 0) { close $ddWrite; open(STDIN, '<&', $ddRead) or die "Unable to redirect xz stdin: $!\n"; close $ddRead; open(STDOUT, '>', $outputFile) or die "Unable to open output file '$outputFile': $!\n"; exec('xz', '-z', "-$xzLevel", "--threads=$xzThreads"); die "Unable to exec xz: $!\n"; } close $ddRead; close $ddWrite; my $ddStatus = waitForChild($ddPid, 'dd'); my $xzStatus = waitForChild($xzPid, 'xz'); if ($ddStatus != 0 || $xzStatus != 0) { unlink $outputFile if -f $outputFile; die "Snapshot export failed (dd status=$ddStatus, xz status=$xzStatus)\n"; } } ## # Wait for a child process and return its normalized exit status. # # @param int $pid Child process ID. # @param string $name Process label for errors. # @return int Exit code, or 255 if terminated by signal. sub waitForChild { my ($pid, $name) = @_; my $waitedPid = waitpid($pid, 0); die "Failed waiting for $name process\n" if $waitedPid == -1; if ($? == -1) { die "$name failed to execute\n"; } if ($? & 127) { my $signal = $? & 127; my $dumpedCore = $? & 128; die "$name terminated by signal $signal" . ($dumpedCore ? ' (core dumped)' : '') . "\n"; } return $? >> 8; } ## # Get an online CPU count from /proc/cpuinfo for xz thread defaults. # # @return int CPU count, minimum 1. sub getCpuCount { my $cpuCount = 0; if (open(my $cpuInfoHandle, '<', '/proc/cpuinfo')) { while (my $line = <$cpuInfoHandle>) { $cpuCount++ if $line =~ /^processor\s*:/; } close $cpuInfoHandle; } $cpuCount = 1 if $cpuCount < 1; return $cpuCount; } ## # Print run details either as a full block or a one-line cron-friendly summary. # # @param hash %summary Summary values for mode, paths, compression, and cleanup. # @return void sub printRunSummary { my %summary = @_; my $cleanupSummary = 'disabled'; if (defined $summary{keepTimeDays}) { $cleanupSummary = "enabled: pattern=$summary{cleanupPattern}, older than $summary{keepTimeDays} days"; } if ($summary{quiet}) { print "backupLV[$summary{mode}] lv=$summary{lv} output=$summary{outputFile} xz=level:$summary{xzLevel},threads:$summary{xzThreads} cleanup=$cleanupSummary\n"; return; } print "Summary:\n"; print " mode: $summary{mode}\n"; print " lv: $summary{lv}\n"; print " snapshot name: $summary{snapshotName}\n"; print " output file: $summary{outputFile}\n"; print " cleanup: $cleanupSummary\n"; print " xz: level=$summary{xzLevel}, threads=$summary{xzThreads}\n"; } ## # Return usage text for command line help. # # @return string Usage text. sub usage { return <<"USAGE"; Usage: $0 --lv vg/lv_name [--lv vg/lv_name ...] --target /path/to/dir [options] $0 --lv-list-file /path/to/lvList.txt --target /path/to/dir [options] $0 -l vg/lv_name [-l vg/lv_name ...] -t /path/to/dir [options] Required: --lv, -l Source logical volume. May be repeated. Use lv_name with --default-vg. --lv-list-file, -L File containing vg/lv or lv_name entries, one per line. --target, -t Destination directory for .img.xz output. Options: --snap-size, -s Snapshot size for lvcreate (default: 10G). --keeptime, -k Delete matching files older than N days. --xz-level, -z xz compression level 0-9 (default: 9). --xz-threads, -T Explicit xz threads (overrides --xz-cpu-percent). --xz-cpu-percent, -p Percent of detected CPUs used for xz threads. --default-vg, -g Default vg for bare lv names from --lv/--lv-list-file. --dry-run, -n Print planned actions without changing the system. --quiet, -q Reduce output for cron; print summary/error only. --help, -h Show this help text. Examples: $0 --lv vg0/data --target /backups/lv --snap-size 10G --keeptime 5 $0 -l data -l router -g vg0 -t /backups/lv -q $0 --lv vg0/data --lv vg0/router --target /backups/lv --quiet $0 --lv-list-file /root/lvsToBackUp --default-vg vg0 --target /backups/lv --keeptime 7 --quiet $0 --lv-list-file /root/lvsToBackUp --target /backups/lv --keeptime 7 --quiet $0 -l vg0/data -t /backups/lv -z 7 -T 4 $0 --lv vg0/data --target /backups/lv --dry-run $0 -l vg0/data -t /backups/lv -q What it does: - Optionally deletes older matching .img.xz files for the same vg/lv. - Creates a thick LVM snapshot. - Streams snapshot data into a timestamped xz-compressed image. - Removes the snapshot after success. Output filename format: /__snapshot_.img.xz USAGE }