Subversion Repositories zfs_utils

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
4 rodolico 1
#! /usr/bin/env perl
2
 
3
#    snapShot: Manage ZFS snapshots
4
#    see http://wiki.linuxservertech.com for additional information
5
#    Copyright (C) 2022  R. W. Rodolico
6
#
7
#    version 1.0, 20220423 RWR
8
#       Initial Release
9
#
10
#    version 1.0.1 20220430 RWR
11
#       Removed some debugging, set so it will always log the actions to /tmp/snapShot
12
#
13
#    version 1.0.2 20220529 RWR
14
#       Fixed error where 86400 was mistyped 864000, replacing all instance of numbers other than 60 and 3600 with contants
15
#       Added DEBUG flag in config which will turn on TESTING, the dump some additional information to screen during run
16
#
17
#    This program is free software: you can redistribute it and/or modify
18
#    it under the terms of the GNU General Public License as published by
19
#    the Free Software Foundation, either version 3 of the License, or
20
#    (at your option) any later version.
21
#
22
#    This program is distributed in the hope that it will be useful,
23
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
24
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25
#    GNU General Public License for more details.
26
#
27
#    You should have received a copy of the GNU General Public License
28
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
29
#
30
# Warning, this script requires non-standard Perl modules YAML::Tiny and Hash::Merge
31
# Under Debian:  apt install libyaml-tiny-perl libhash-merge-simple-perl
32
# Under FreeBSD: cpan -i Hash::Merge::Simple YAML::Tiny
33
 
34
 
35
use strict;
36
use warnings;
37
 
38
use version; our $VERSION = version->declare( 'v1.0.2');
39
use Data::Dumper;
40
use Time::Local;
41
use POSIX qw(strftime);
42
use YAML::Tiny; # apt-get libyaml-tiny-perl under debian, BSD Systems: cpan -i YAML::Tiny
43
use Hash::Merge::Simple qw/ merge clone_merge /; # apt install libhash-merge-simple-perl or cpan -i Hash::Merge::Simple
44
 
45
 
46
# globals
47
my $CONFIG_FILE_NAME = 'snapShot.yaml';
48
my $SECONDS_PER_DAY = 86400;
49
my $SECONDS_PER_WEEK = 7 * $SECONDS_PER_DAY;
50
my $SECONDS_PER_MONTH = 30.5 * $SECONDS_PER_DAY;
51
my $SECONDS_PER_YEAR = 365.2425 * $SECONDS_PER_DAY;
52
 
53
# This will be read in from snapShot.yaml
54
my $config;
55
 
56
#
57
# find where the script is actually located as cfg should be there
58
#
59
sub getScriptLocation {
60
   use strict;
61
   use File::Spec::Functions qw(rel2abs);
62
   use File::Basename;
63
   return dirname(rel2abs($0));
64
}
65
 
66
#
67
# Read the configuration file from current location 
68
# and return it as a string
69
#
70
sub readConfig {
71
   my $scriptLocation = &getScriptLocation();
72
   if ( -e "$scriptLocation/$CONFIG_FILE_NAME" ) {
73
      my $yaml = YAML::Tiny->read( "$scriptLocation/$CONFIG_FILE_NAME" );
74
      # use clone_merge to merge conf file into $config
75
      # overwrites anything in $config if it exists in the config file
76
      $config = clone_merge( $config, $yaml->[0] );
77
      return 1;
78
   }
79
   return 0;
80
}
81
 
82
 
83
# parse one single line from the output of `zfs list [-t snapshot]`
84
sub parseListing {
85
   my ($line,$keys) = @_;
86
   chomp $line;
87
   my %values;
88
   @values{@$keys} = split( /\s+/, $line );
89
   return \%values;
90
}      
91
 
92
 
93
# this will parse the date out of the snapshots and put the values into
94
# the hash {'date'}
95
sub parseSnapshots {
96
   my ( $snapShots, $config) = @_;
97
   my $keys = $config->{'snapshot'}->{'parseFields'};
98
   foreach my $snapShot ( keys %$snapShots ) {
99
      my %temp;
100
      # run the regex, capture the output to an array, then populate the hash %temp
101
      # using the regex results as the values, and $keys as the keys
102
      @temp{@$keys} = ( $snapShot =~ m/$config->{'snapshot'}->{'parse'}/ );
103
      # while we're here, calculate the unix time (epoch). NOTE: month is 0 based
104
      $temp{'unix'} = timelocal( 0,$temp{'minute'},$temp{'hour'},$temp{'day'},$temp{'month'}-1,$temp{'year'} );
105
      # put this into our record
106
      $snapShots->{$snapShot}->{'date'} = \%temp;
107
   }
108
}
109
 
110
# run $command, then parse its output and return the results as a hashref
111
# $command is one of zfs list or zfs list -t snapshot
112
# In other words, get all datasets/volumes or get all snapshots
113
sub getListing {
114
   my ($configuration, $regex, $command )  = @_;
115
   my %dataSets;
116
 
117
   # get all datasets/volumes or snapshots
118
   my @zfsList = `$command`;
119
   foreach my $thisSet ( @zfsList ) {
120
      # parse the line into its portions. The only one we use right now is name
121
      my $temp = &parseListing( $thisSet, $configuration->{'listingKeys'} );
122
      if (  $temp->{'name'} =~ m/^($regex)$/ ) { # it matches the regex we're using, so save it
123
         $dataSets{$temp->{'name'}} = $temp;
124
      }
125
   }
126
   return \%dataSets; # return all entries we are looking for
127
}
128
 
129
# will convert something like 1 day to the number of seconds ($SECONDS_PER_DAY) for math.
130
# month and year are approximations (30.5 day = a month, 365.2425 days is a year)
131
# For month and year, use the int function to convert back to integer
132
sub period2seconds {
133
   my ($count, $unit) = ( shift =~ m/\s*(\d+)\s*([a-z]+)\s*/i );
134
   $unit = lc $unit;
135
   if ( $unit eq 'hour' ) {
136
      $count *= 3600;
137
   } elsif ( $unit eq 'day' ) {
138
      $count *= $SECONDS_PER_DAY;
139
   } elsif ( $unit eq 'week' ) {
140
      $count *= $SECONDS_PER_WEEK;
141
   } elsif ( $unit eq 'month' ) {
142
      $count *= int( $SECONDS_PER_MONTH );
143
   } elsif ( $unit eq 'year' ) {
144
      $count *= int( $SECONDS_PER_YEAR );
145
   } else {
146
      die "Unknown units [$unit] in period2seconds\n";
147
   }
148
   return $count;
149
}
150
 
151
# quick convert unix time stamp to YYYY-MM-DD HH:MM
152
sub unix2Gregorian {
153
   use POSIX qw(strftime);
154
   my $timestamp = shift;
155
 
156
   return strftime "%F %R", localtime $timestamp ;
157
}
158
 
159
# just converts a number of seconds to DD day HH:MM
160
 
161
sub secondsToHuman {
162
   my $seconds = shift;
163
 
164
   my $days = int( $seconds / $SECONDS_PER_DAY );
165
   $seconds -= $days * $SECONDS_PER_DAY;
166
   my $hours = int( $seconds / 60);
167
   $seconds -= $hours * 60;
168
   my $minutes = int( $seconds / 60 );
169
   $seconds -= $minutes * 60;
170
   return sprintf( '%02dD %02dH%02dM', $days, $hours, $minutes );
171
}
172
 
173
# Merges datasets, snapshots and some stuff from the configuration into the datasets
174
# hash. After this, $config and $snapshots should no longer be necessary
175
sub mergeData {
176
   my ($datasets,$snapshots,$config) = @_;
177
   my $confKeys = $config->{'datasets'};
178
   foreach my $thisDataset ( keys %$datasets ) {
179
      # go through each configuration entry and see if we match the current dataset
180
      foreach my $conf (keys %$confKeys ) {
181
         if ( $thisDataset =~ m/^$conf$/ ) { # found it, so store the configuration values into the dataset
182
            $datasets->{$thisDataset}->{'recursive'} = $confKeys->{$conf}->{'recursive'};
183
            $datasets->{$thisDataset}->{'frequency'} = &period2seconds( $confKeys->{$conf}->{'frequency'} );
184
            $datasets->{$thisDataset}->{'retention'} = &period2seconds( $confKeys->{$conf}->{'retention'} );
185
            last; # there is only one, so no need to process any more for this configuration key
186
         } # if
187
      } # foreach
188
      # do the same for the snapshots we found; bind them to the data set
189
      foreach my $snapshot ( keys %$snapshots ) {
190
         if ( $snapshot =~ m/^$thisDataset@/ ) { # this is a match
191
            # copy the snapshot into the dataset
192
            $datasets->{$thisDataset}->{'snapshots'}->{$snapshot} = $snapshots->{$snapshot};
193
            # track the latest snapshot (we use this to decide whether it is time to add a new one)
194
            $datasets->{$thisDataset}->{'lastSnap'} = $snapshots->{$snapshot}->{'date'}->{'unix'}
195
               if ! defined( $datasets->{$thisDataset}->{'lastSnap'} ) || $datasets->{$thisDataset}->{'lastSnap'} < $snapshots->{$snapshot}->{'date'}->{'unix'};
196
            # delete the snapshot, to free up memory
197
            delete $snapshots->{$snapshot};
198
         } # if
199
      } # foreach
200
   } # foreach
201
} # sub mergeData
202
 
203
 
204
# check to see if a particular snapshot is ready to be destroyed, ie right now is greater than the retention period
205
# if $recurive is true, add the '-r' to the command to do a recursive destroy
206
sub checkRetention {
207
   my ( $retentionPeriod, $recursive, $snapshots, $now ) = @_;
208
   print "\tRemoving snapshots older than " . &unix2Gregorian( $now - $retentionPeriod ) . "\n"  if $config->{'DEBUG'};
209
   my @toDelete; # an array of destroy commands
210
   foreach my $thisSnapshot ( keys %$snapshots ) {
211
      # print "checking $thisSnapshot\n\tNow: $now\n\tDate: $snapshots->{$thisSnapshot}->{date}->{unix}\n\tRetention: $retentionPeriod\n\n";
212
      if ( $now - $snapshots->{$thisSnapshot}->{'date'}->{'unix'} > $retentionPeriod ) { # it is too old
213
         push ( @toDelete, ( 'zfs destroy ' . ($recursive ? '-r ' : '') . $thisSnapshot ) ); # list it to be destroyed
214
      }
215
   }
216
   return @toDelete; # just return the list of destroy commands to be executed
217
}   
218
 
219
 
220
# just return the command to create a new snapshot. Very simple, but I wanted the code to be isolated in case something needed
221
# to change. Basically, zfs snapshot [-r] datasetname@template
222
sub makeSnapshot {
223
   my ( $datasetName, $recursive, $snapshotName ) = @_;
224
   return 
225
      'zfs snapshot ' . 
226
      ($recursive ? '-r ' : '') . 
227
      $datasetName . $snapshotName;
228
}
229
 
230
# this is the biggie; everything leads to here. We will take every dataset/volume we found, and decide whether some old snapshots
231
# need to be destroyed, and whether a new snapshot needs to be created.
232
sub process {
233
   my ( $datasets, $now, $snapshotName, $slop ) = @_;
234
   my @toDelete; # will hold all the destroy commands
235
   my @toAdd; # will hold all the create commands
236
 
237
   foreach my $thisDataset ( keys %$datasets ) { # Look at each dataset/volume in turn
238
      if ( $config->{'DEBUG'} ) {
239
         print "Found Datasset $thisDataset\n";
240
         print "\tNext Snapshot due " . 
241
            &unix2Gregorian( $datasets->{$thisDataset}->{'lastSnap'} + $datasets->{$thisDataset}->{'frequency'} - $slop ) . "\n";
242
         print "\tRetention period set to " . &secondsToHuman( $datasets->{$thisDataset}->{'retention'} ) . "\n";
243
      }
244
      # if any snapshots need to be destroyed, add them to @toDelete
245
      push( @toDelete, 
246
         &checkRetention( 
247
         $datasets->{$thisDataset}->{'retention'}, 
248
         $datasets->{$thisDataset}->{'recursive'}, 
249
         $datasets->{$thisDataset}->{'snapshots'}, 
250
         $now )
251
         );
252
      # if there is no snapshaor or it is time to add a new snapshot, add it to @toAdd
253
      if ( !$datasets->{$thisDataset}->{'lastSnap'} || $datasets->{$thisDataset}->{'lastSnap'} + $datasets->{$thisDataset}->{'frequency'} - $slop < $now ) {
254
         push @toAdd, &makeSnapshot( $thisDataset, $datasets->{$thisDataset}->{'recursive'}, $snapshotName )
255
      }
256
   }
257
   # return the actions, deletions first, adds second (executed in that order)
258
   return ( @toDelete, @toAdd );
259
}   
260
 
261
# Run 0 or more commands
262
# the first thing on the stack is a flag for testing
263
# everything after that is an ordered list of commands to be executed.
264
# If any command fails, all subsequent commands abort
265
sub run {
266
   my $testing = shift;
267
   return 0 unless @_; # bail if there are no commands to run
268
   # dump the run to /tmp so the user can see the last one written
269
   open LOG, ">/tmp/snapShot" or die "could not write to /tmp/snapShot: $!\n";
270
   print LOG join( "\n", @_ ) . "\n";
271
   close LOG;
272
   unless ( $testing ) { # run the commands if we're not testing
273
      my $out; # capture all output
274
      while ( my $command = shift ) { # for each command on the stack
275
         $out .= `$command` . "\n"; # add it to $out
276
         if ( $? ) { # we had an error, add debugging text, the end program
277
            $out .= "Error executing command\n\t$command\n\t";
278
            if ($? == -1) {
279
                $out .= "failed to execute $command: $!";
280
            } elsif ($? & 127) {
281
                $out .= sprintf( "child died with signal %d, %s coredump", ($? & 127),  ($? & 128) ? 'with' : 'without' );
282
            } else {
283
                $out .= sprintf( "child exited with value %d", $? >> 8 );
284
            }
285
            $out .= "\n";
286
            return $out;
287
         }
288
      }
289
   }
290
   return 0; # we succeeded
291
}
292
 
293
&readConfig() or die "Could not read config file: $!\n";
294
# if debug set, be sure testing is set also, so no actions are taken
295
$config->{'TESTING'} = 1 if $config->{'DEBUG'};
296
 
297
# we're pre-calculating some things so we don't do it over and over for each entry
298
# grab the time once
299
my $now = time;
300
# create the string to be used for all snapshots, using $now and the template provided
301
my $snapshotName = '@' . strftime($config->{'snapshot'}->{'template'},localtime $now);
302
# Create the dataset regex by joing all of the regexes defined.
303
$config->{'dataset_regex'} = '(' . join( ')|(', keys %{ $config->{'datasets'} }  ) . ')' unless $config->{'dataset_regex'};
304
#print $config{'dataset_regex'} . "\n";
305
$config->{'snapshot_regex'} = '(' . $config->{'dataset_regex'} . ')@' . $config->{'snapshot'}->{'parse'};
306
#print $config->{'snapshot_regex'} . "\n\n";
307
 
308
#die Dumper( $config ) . "\n";   
309
# first, find all datasets which match our keys
310
my $dataSets = &getListing( $config, $config->{'dataset_regex'}, 'zfs list'  );
311
# and, find all snapshots that match
312
my $snapshots = &getListing( $config, $config->{'snapshot_regex'}, 'zfs list -t snapshot'  );
313
# get the date/time of the snapshots and store them in the hash
314
&parseSnapshots($snapshots, $config );
315
# mergeData the snapshots into the datasets for convenience
316
&mergeData( $dataSets, $snapshots, $config );
317
# Now, let's do the actual processing
318
my @commands  = &process( $dataSets, $now, $snapshotName, &period2seconds( $config->{'slop'} ) );
319
#print join ( "\n", @commands ) . "\n";
320
my $errors;
321
print "Error: $errors\n" if $errors = &run( $config->{'TESTING'}, @commands );
322
 
323
# print Dumper( $dataSets );
324
#print Dumper( $snapshots );
325
 
326
#print join ("\n", sort keys( %$dataSets ) ) . "\n\n";
327
#print join( "\n", sort keys( %$snapshots ) ) . "\n";
328
 
329
1;