ZFS Snapshotting in Perl

It’s almost 11pm Sunday night, and I wanted to get this out. The main reason why I wanted this is I’m running Solaris 10 11/06 at home, and having a automated way of snapshotting was important for me. I wrote this in Perl and will behave much like like Network Appliance snapshotting. I’ve used it for some time, and it works beautifully. If you like it, or find it useful, let me know. Please, no technical support questions. Cheers.

So, what you want to do is this:

1) Create a file called /etc/snapshots, and put entries in it like this:

# zfs_vol        months  weeks  days  hours@<list>
###################################################
main/audit       2        4      7     6@0,8,16
tank/zones       2        4      7     2
main/cfengine    2        4      7     6@0,8,16

The line above means that ‘main/audit’ will have two months of snapshots called month.0, and month.1, four weeks of snapshots (week.0, week.1), etc. The ‘hours’ one is basically it’ll keep 6 ‘hour based’ snapshots done at midnight, 8am, and 4pm.

2) Next, put this script somewhere, and run it from cron every hour:

#!/bin/env perl

##################################################################
#
# ZFS Snapshotting
#
# Author: Eric Bullen
# Date:   14-Oct-2007
# Desc:   This script reads a file $snap_cfg to find out zfs volumes
#         that are scheduled to be snapshotted on regular time periods
#         (weekly, hourly, daily, monthly), and creates those snapshots.
#         If the number of said snapshots exceeds the defined number in
#         the config file, it deletes the old ones, and rotates.
#
##################################################################

use strict;
use warnings;
use Time::Local;

my $snap_cfg = "/etc/snapshots";
my $debug = 0;
my $pid_file = "/var/tmp/make_snapshot.pid";
my $zfs_fs_list = "zfs list -H -t filesystem";
my $zfs_all_list = "zfs list -H -o name,creation";

my %month_hash = ("Jan" => 1, "Feb" => 2, "Mar" => 3, "Apr" => 4,
                  "May" => 5, "Jun" => 6, "Jul" => 7, "Aug" => 8,
                  "Sep" => 9, "Oct" => 10, "Nov" => 11, "Dec" => 12);

my %zfs_hash = ();
my %zfs_snapped = ();
my $now = time;
my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime($now);

sub str2time {
   my $date_str = shift;

   my ($month, $day, $hour, $minute, $year) = $date_str =~ m/\w+\s+(\w+)\s+(\d+)\s+(\d+):(\d+)\s+(\d+)/;

   return timelocal(0, $minute, $hour, $day, $month_hash{$month} - 1, $year);
}

# This will turn (weekly.33, weekly.6, weekly.111) into the right order by the number..
sub sort_snapnames {
   my @snapnames = @_;
   return (sort {my ($x) = $a =~ m/(\d+)/; my ($y) = $b =~ m/(\d+)/; $y <=> $x} @snapnames);
}

sub command {
   my $command = shift;
   my $output = "";
   my $pid = "";

   eval {
      local $SIG{ALRM} = sub { die "TIMEOUT" };

      alarm(10);
      $pid = open(PIPE, "$command|") or die "Can't run $command: $!\n";

      while(<PIPE>) {
         $output .= $_;
      }
      close(PIPE);
      alarm(0);
   };

   if ($@) {
      die $@ unless $@ =~ /TIMEOUT/;
      kill 9, $pid;
      close(PIPE);
      $? ||= 9;
   }

   return $output;
}
sub rotate_n_create {
   my ($volume, $type, $keep_count) = @_;

   my $vol_key = $volume;

   # Create the snapshot first. Hopefully this fixes
   # the weird race condition..

   # If I'm not keeping any, no need to create any either.
   if ($keep_count) {
      print "Making snapshot $vol_key\@$type.TEMP\n" if ($debug);
      command("zfs snapshot $vol_key\@$type.TEMP");
   }

   if (exists($zfs_hash{$vol_key}{"snap"}{$type})) {
      my %snaps = %{$zfs_hash{$vol_key}{"snap"}{$type}};

      my @sorted_snaps = ();

      # Sort by oldest creation date first (this is JUST for deleting the oldest)
      foreach my $snap_name (reverse sort {$snaps{$a} <=> $snaps{$b}} keys(%snaps)) {
         push(@sorted_snaps, $snap_name);
      }

      # Delete old ones
      while (scalar(@sorted_snaps) >= $keep_count) {
         my $snap = pop(@sorted_snaps);

         if ($snap) {
            print "!! Deleting $vol_key\@$snap\n" if ($debug);
            command("zfs destroy $vol_key\@$snap");
         }
      }

      # Rename the snapshots, incrementing the number, ignoring creation times
      if ($keep_count) {
         foreach my $snap (sort_snapnames(@sorted_snaps)) {
            my ($name, $counter) = $snap =~ m/^([^\.]+)\.(\d+)/;

            $counter++;

            print "Renaming $vol_key\@$snap to $vol_key\@$name.$counter\n" if ($debug);
            command("zfs rename $vol_key\@$snap $vol_key\@$name.$counter");
         }
      }
   }
   if ($keep_count) {
      # Create most current snap
      print "Creating $vol_key\@$type.0 (from rename of $vol_key\@$type.TEMP)\n" if ($debug);
      command("zfs rename $vol_key\@$type.TEMP $vol_key\@$type.0");
   }

   # If I ever want to do wildcard matching, plug this in...

   #foreach my $vol_key (sort keys(%zfs_hash)) {
   #  print "Checking $vol_key...\n";
   #  if ($vol_key =~ m/$volume/) {
   #  }
   #}
}

# This checks to see if the current unix time (in epoch seconds) falls
# on a given 'weekly', 'hourly', etc. mark.
sub tick {
   my ($period, $time, $hours) = @_;

   my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime($time);

   if ($period eq "hourly") {
      if ($hours) {
         foreach my $hr (split(/,/, $hours)) {
            if ($hr == $hour) {
               if ($min == 0) {
                  # Just in case this script is not run on the hour...
                  return 1;
               } else {
                  return 0;
               }
            }
         }
      } else {
         if ($min == 0) {
            return 1;
         } else {
            return 0;
         }
      }
   } elsif ($period eq "daily") {
      # No doing on sunday ($wday == 1) or more accurately,
      # Monday morning.
      # - Commenting out the line that skips weeks. Doing 'dailies' for
      # sunday too.
      # if ($hour == 0 && $min == 0 && $wday != 1) {

      if ($hour == 0 && $min == 0) {
         return 1;
      } else {
         return 0;
      }
   } elsif ($period eq "weekly") {
      # Sunday is $wday == 0, but I want the
      # end of sunday (ie, monday at 0:00am), so
      # $wday == 1

      if ($hour == 0 && $min == 0 && $wday == 1) {
         return 1;
      } else {
         return 0;
      }
   } elsif ($period eq "monthly") {
      if ($hour == 0 && $min == 0 && $mday == 1) {
         return 1;
      } else {
         return 0;
      }
   } else {
      die "I don't know what you're asking for.\n";
   }
}

# Check to see if the snap config file exists, otherwise, exit
# silently.

if ( -f $pid_file ) {
   print "$pid_file already exists. Exiting now.\n";
   exit;
} else {
   open (FH, ">$pid_file") || die "Can't open pid file $pid_file: $!\n";
   close(FH);
}

if ( -f $snap_cfg ) {
   # Get information about the zfs volumes
   open(FH, "$zfs_all_list|") || die "Can't run $zfs_all_list: $!\n";
   while(<FH>) {
      chomp;
      my $line = $_;

      my($name, $creation_date) = $line =~ m/(\S+)\s+(.*)/;

      next if (!$name || !$creation_date);

      $creation_date = str2time($creation_date);

      #print "($name, $creation_date)\n";

      my $snapname = "";
      if ($name !~ m/@/) {
         $zfs_hash{$name}{"created"} = $creation_date;

      } else {
         ($name, $snapname) = m/([^@]+)@([^@\s]+)/;

         my $period = "";

         if ($snapname =~ m/^hourly/) {
            $period = "hourly";

         } elsif ($snapname =~ m/^daily/) {
            $period = "daily";

         } elsif ($snapname =~ m/^weekly/) {
            $period = "weekly";

         } elsif ($snapname =~ m/^monthly/) {
            $period = "monthly";
         }

         if ($period) {
            $zfs_hash{$name}{"snap"}{$period}{$snapname} = $creation_date;
         }
      }

   }
   close(FH);

   # Read the config
   open(FH, "<$snap_cfg") || die "Can't open $snap_cfg: $!\n";
   while(<FH>) {
      chomp;
      next if (m/^(#|\s$)/);

      my %count = ();
      my $vol = "";
      my @hours = ();
      my $pre_run_script = "";

      ($vol, $count{"monthly"}, $count{"weekly"}, $count{"daily"}, $count{"hourly"}, $pre_run_script) = m/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(\S*)$/;

      ($count{"hourly"}, @hours) = $count{"hourly"} =~ m/(\d+)\@?(\S*)/;

      for my $period ("monthly", "weekly", "daily", "hourly") {
         if (tick($period, $now, @hours)) {
            rotate_n_create($vol, $period, $count{$period});
         }
      }
   }
   close(FH);
}

unlink($pid_file);

Enjoy.

Leave a Reply

You must be logged in to post a comment.