Rev 98 | Rev 100 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download | RSS feed
#! /usr/bin/env perl
# snapShot: Manage ZFS snapshots
# see http://wiki.linuxservertech.com for additional information
# Copyright (C) 2022 R. W. Rodolico
#
# version 1.0, 20220423
# Initial Release
#
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Warning, this script requires YAML::Tiny and Hash::Merge Perl Modules to be installed
# Under Debian: apt install libyaml-tiny-perl libhash-merge-simple-perl
# Under FreeBSD: cpan -i Hash::Merge::Simple YAML::Tiny
use strict;
use warnings;
use Data::Dumper;
use Time::Local;
use POSIX qw(strftime);
use YAML::Tiny; # apt-get libyaml-tiny-perl under debian, BSD Systems: cpan -i YAML::Tiny
use Hash::Merge::Simple qw/ merge clone_merge /; # apt install libhash-merge-simple-perl or cpan -i Hash::Merge::Simple
# globals
my $CONFIG_FILE_NAME = 'snapShot.yaml';
# This will be read in from snapShot.yaml
my $config;
#
# find where the script is actually located as cfg should be there
#
sub getScriptLocation {
use strict;
use File::Spec::Functions qw(rel2abs);
use File::Basename;
return dirname(rel2abs($0));
}
#
# Read the configuration file from current location
# and return it as a string
#
sub readConfig {
my $scriptLocation = &getScriptLocation();
if ( -e "$scriptLocation/$CONFIG_FILE_NAME" ) {
my $yaml = YAML::Tiny->read( "$scriptLocation/$CONFIG_FILE_NAME" );
# use clone_merge to merge conf file into $config
# overwrites anything in $config if it exists in the config file
$config = clone_merge( $config, $yaml->[0] );
return 1;
}
return 0;
}
# parse one single line from the output of `zfs list [-t snapshot]`
sub parseListing {
my ($line,$keys) = @_;
chomp $line;
my %values;
@values{@$keys} = split( /\s+/, $line );
return \%values;
}
# this will parse the date out of the snapshots and put the values into
# the hash {'date'}
sub parseSnapshots {
my ( $snapShots, $config) = @_;
my $keys = $config->{'snapshot'}->{'parseFields'};
foreach my $snapShot ( keys %$snapShots ) {
my %temp;
# run the regex, capture the output to an array, then populate the hash %temp
# using the regex results as the values, and $keys as the keys
@temp{@$keys} = ( $snapShot =~ m/$config->{'snapshot'}->{'parse'}/ );
# while we're here, calculate the unix time (epoch). NOTE: month is 0 based
$temp{'unix'} = timelocal( 0,$temp{'minute'},$temp{'hour'},$temp{'day'},$temp{'month'}-1,$temp{'year'} );
# put this into our record
$snapShots->{$snapShot}->{'date'} = \%temp;
}
}
# run $command, then parse its output and return the results as a hashref
sub getListing {
my ($configuration, $regex, $command ) = @_;
my %dataSets;
# get all datasets
my @zfsList = `$command`;
foreach my $thisSet ( @zfsList ) {
my $temp = &parseListing( $thisSet, $configuration->{'listingKeys'} );
if ( $temp->{'name'} =~ m/^($regex)$/ ) {
$dataSets{$temp->{'name'}} = $temp;
}
}
return \%dataSets;
}
# will convert something like 1 day to the number of seconds (86400) for math.
# month and year are approximations (30.5 day = a month, 365.2425 days is a year)
sub period2seconds {
my ($count, $unit) = ( shift =~ m/\s*(\d+)\s*([a-z]+)\s*/i );
$unit = lc $unit;
if ( $unit eq 'hour' ) {
$count *= 3600;
} elsif ( $unit eq 'day' ) {
$count *= 86400;
} elsif ( $unit eq 'week' ) {
$count *= 864000 * 7;
} elsif ( $unit eq 'month' ) {
$count *= 864000 * 30.5;
} elsif ( $unit eq 'year' ) {
$count *= 86400 * 365.2425;
} else {
die "Unknown units [$unit] in period2seconds\n";
}
return $count;
}
# Merges datasets, snapshots and some stuff from the configuration into the datasets
# hash
sub mergeData {
my ($datasets,$snapshots,$config) = @_;
my $confKeys = $config->{'datasets'};
foreach my $thisDataset ( keys %$datasets ) {
foreach my $conf (keys %$confKeys ) {
if ( $thisDataset =~ m/^$conf$/ ) {
$datasets->{$thisDataset}->{'recursive'} = $confKeys->{$conf}->{'recursive'};
$datasets->{$thisDataset}->{'frequency'} = &period2seconds( $confKeys->{$conf}->{'frequency'} );
$datasets->{$thisDataset}->{'retention'} = &period2seconds( $confKeys->{$conf}->{'retention'} );
last;
} # if
} # foreach
foreach my $snapshot ( keys %$snapshots ) {
if ( $snapshot =~ m/^$thisDataset@/ ) { # this is a match
# copy the snapshot into the dataset
$datasets->{$thisDataset}->{'snapshots'}->{$snapshot} = $snapshots->{$snapshot};
# track the latest snapshot
$datasets->{$thisDataset}->{'lastSnap'} = $snapshots->{$snapshot}->{'date'}->{'unix'}
if ! defined( $datasets->{$thisDataset}->{'lastSnap'} ) || $datasets->{$thisDataset}->{'lastSnap'} < $snapshots->{$snapshot}->{'date'}->{'unix'};
# delete the snapshot
delete $snapshots->{$snapshot};
} # if
} # foreach
} # foreach
} # sub mergeData
sub checkRetention {
my ( $retentionPeriod, $recursive, $snapshots, $now ) = @_;
my @toDelete;
foreach my $thisSnapshot ( keys %$snapshots ) {
# print "checking $thisSnapshot\n\tNow: $now\n\tDate: $snapshots->{$thisSnapshot}->{date}->{unix}\n\tRetention: $retentionPeriod\n\n";
if ( $now - $snapshots->{$thisSnapshot}->{'date'}->{'unix'} > $retentionPeriod ) {
my $command = 'zfs destroy ' . ($recursive ? '-r ' : '') . $thisSnapshot;
push @toDelete, $command;
}
}
return @toDelete;
}
sub makeSnapshot {
my ( $datasetName, $recursive, $snapshotName ) = @_;
return
'zfs snapshot ' .
($recursive ? '-r ' : '') .
$datasetName . $snapshotName;
}
sub process {
my ( $datasets, $now, $snapshotName, $slop ) = @_;
my @actions;
my @toDelete;
my @toAdd;
foreach my $thisDataset ( keys %$datasets ) {
push( @toDelete,
&checkRetention(
$datasets->{$thisDataset}->{'retention'},
$datasets->{$thisDataset}->{'recursive'},
$datasets->{$thisDataset}->{'snapshots'},
$now )
);
if ( $datasets->{$thisDataset}->{'lastSnap'} + $datasets->{$thisDataset}->{'frequency'} < $now + $slop ) {
push @toAdd, &makeSnapshot( $thisDataset, $datasets->{$thisDataset}->{'recursive'}, $snapshotName )
}
}
return ( @toDelete, @toAdd );
}
sub run {
my $testing = shift;
# bail if there are no commands to run
return 0 unless @_;
if ( $testing ) { # don't do it, just dump the commands
open LOG, ">/tmp/snapShot" or die "could not write to /tmp/snapShot: $!\n";
print LOG join( "\n", @_ ) . "\n";
close LOG;
} else {
my $out;
return 'Not running right now';
while ( my $command = shift ) {
$out .= `$command` . "\n";
if ( $? ) { # we had an error
$out .= "Error executing command\n\t$command\n\t";
if ($? == -1) {
$out .= "failed to execute $command: $!";
} elsif ($? & 127) {
$out .= sprintf( "child died with signal %d, %s coredump", ($? & 127), ($? & 128) ? 'with' : 'without' );
} else {
$out .= sprintf( "child exited with value %d", $? >> 8 );
}
$out .= "\n";
return $out;
}
}
}
return 0;
}
&readConfig() or die "Could not read config file: $!\n";
# grab the time once
my $now = time;
# create the string to be used for all snapshots, using $now and the template provided
my $snapshotName = '@' . strftime($config->{'snapshot'}->{'template'},localtime $now);
# Create the dataset regex for later use
$config->{'dataset_regex'} = '(' . join( ')|(', keys %{ $config->{'datasets'} } ) . ')' unless $config->{'dataset_regex'};
#print $config{'dataset_regex'} . "\n";
$config->{'snapshot_regex'} = '(' . $config->{'dataset_regex'} . ')@' . $config->{'snapshot'}->{'parse'};
#print $config->{'snapshot_regex'} . "\n\n";
#die Dumper( $config ) . "\n";
# first, find all datasets which match our keys
my $dataSets = &getListing( $config, $config->{'dataset_regex'}, 'zfs list' );
# and, find all snapshots that match
my $snapshots = &getListing( $config, $config->{'snapshot_regex'}, 'zfs list -t snapshot' );
# get the date/time of the snapshots and store them in the hash
&parseSnapshots($snapshots, $config );
# mergeData the snapshots into the datasets for convenience
&mergeData( $dataSets, $snapshots, $config );
# Now, let's do the actual processing
my @commands = &process( $dataSets, $now, $snapshotName, &period2seconds( $config->{'slop'} ) );
#print join ( "\n", @commands ) . "\n";
my $errors;
print "Error: $errors\n" if $errors = &run( $config->{'TESTING'}, @commands );
# print Dumper( $dataSets );
#print Dumper( $snapshots );
#print join ("\n", sort keys( %$dataSets ) ) . "\n\n";
#print join( "\n", sort keys( %$snapshots ) ) . "\n";
1;