#!/usr/local/bin/perl -w
# FreeBSD UFS2 Snapshot Management Tool v1.0
#
# Wu Chin-Hao <wchunhao@cs.nctu.edu.tw>
#
# If you are to send feedback, 
# plz include 'FreeBSD Port SNAP' in the subject.

use Time::Local;
use Getopt::Std;

%opt = ();

getopts('pmhc:', \%opt) or &usage; 
&usage if $opt{h};

$conf_file = (defined $opt{c})? $opt{c} : "/usr/local/etc/snap.conf";

do $conf_file or die "Cannot found configuration file at $conf_file";

&check_config;

if (defined $opt{m}){
	&show_manual_files(@ARGV);
	exit;
}

## create new snapshot
for $base (keys %fs) {
	next unless $fs{$base}{enable};

	my $dir = "$base/$fs{$base}{dir}";
	my $all = $fs{$base}{all};
	my $w = $fs{$base}{weekly};
	my $d = $fs{$base}{daily};
	my $h = $fs{$base}{hourly};
	my $at = $fs{$base}{atHour};

	my $snapTime = time;
	my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime($snapTime);
	$mon ++;
	$year = $year % 100;
	my $snapDate = sprintf "%02d%02d%02d_%02d:%02d:%02d", $year, $mon, $mday, $hour, $min, $sec;

	if (opendir DIR, $dir){
		my @files = grep {! /^(\.|\.\.)$/} readdir DIR;

		my $timeDiff = ($snapTime - &getSnapTime($dir, "hour.0"))/60;
		my $doNow = 0;
		if ($at) {
			for (@$at){
				if ($_ == $hour) { $doNow = 1; last }
			}
		} else { $doNow = 1 }

		if ($opt{p} and $doNow and ($min < 5 or $min > 55) and ( $timeDiff > 55))
		{ # Considered as periodic snapshot and then do hourly rotation
			if ($debug) {
				print "=> Periodic snapshot for $base\n";
			}
			my $last = 0;
			for (@files){
				if (/^hour\.(\d+)$/) { $last = $1 if $1 > $last }
			}
			if ($last >= $h - 1) { &delete($dir, "hour.$last") }
			for (sort { 
					$a =~ /^hour\.(\d+)$/; my $a1= $1;
					$b =~ /^hour\.(\d+)$/; my $b1= $1;
					$b1 <=> $a1; # decreasing
					} 
					grep { /^hour\.(\d+)$/ } @files){
				/^hour\.(\d+)$/;
				system "$config{mv} $dir/hour.$1 $dir/hour.".($1 + 1) 
					if -f "$dir/hour.$1";
				if ($debug) {
					print "Moving $dir/hour.$1 to $dir/hour.", ($1 + 1), "\n";
				}
			}
			rewinddir DIR;
			@files = grep {! /^(\.|\.\.)$/} readdir DIR;
			if (&total_inodes($dir) >= $all) { # still no space after rotation?
				warn "Cannot create more snapshots, $base skipped..";
				next
			} 
			&createSnap("$dir/$snapDate", $base);
			link "$dir/$snapDate", "$dir/hour.0";
			if ($debug) {
				print "$dir/hour.0 created\n";
			}
# do daily rotation
			rewinddir DIR;
			@files = grep {! /^(\.|\.\.)$/} readdir DIR or warn "cannot readdir when doing daily rotation";
			if (($hour == 0 and $min < 5) or ($hour == 23 and $min > 55)){
				$last = 0;
				for (@files){
					if (/^daily\.(\d+)$/) { $last = $1 if $1 > $last }
				}
				if ($last >= $d - 1) { &delete($dir, "daily.$last") }
				for (sort { 
						$a =~ /^daily\.(\d+)$/; my $a1= $1;
						$b =~ /^daily\.(\d+)$/; my $b1= $1;
						$b1 <=> $a1; # decreasing
						} 
						grep { /^daily\.(\d+)$/ } @files){
					/^daily\.(\d+)$/;
					system "$config{mv} $dir/daily.$1 $dir/daily.".($1 + 1)
						if -f "$dir/daily.$1";
					if ($debug) {
						print "Moving $dir/daily.$1 to $dir/daily.".($1 + 1)."\n";
					}
				}
				link "$dir/$snapDate", "$dir/daily.0" or warn "link $snapDate to daily.0 failed";
				if ($debug) {
					print "$dir/daily.0 created\n";
				}
			}
# do weekly rotation
			rewinddir DIR;
			@files = grep {! /^(\.|\.\.)$/} readdir DIR or warn "cannot readdir when doing weekly rotation";
			if ($wday == 0 and 
					(($hour == 0 and $min < 5) or ($hour == 23 and $min > 55)) ){
				$last = 0;
				for (@files){
					if (/^week\.(\d+)$/ and $1 > $last) { $last = $1 }
				}
				if ($last >= $w - 1) { &delete($dir, "week.$last") }
				for (sort { 
						$a =~ /^week\.(\d+)$/; my $a1= $1;
						$b =~ /^week\.(\d+)$/; my $b1= $1;
						$b1 <=> $a1; # decreasing
						} 
						grep { /^week\.(\d+)$/ } @files){
					/^week\.(\d+)$/;
					system "$config{mv} $dir/week.$1 $dir/week.".($1 + 1)
						if -f "$dir/week.$1";
					if ($debug) {
						print "Moving $dir/week.$1 to $dir/week.".($1 + 1)."\n";
					}
				}
				link "$dir/$snapDate", "$dir/week.0" or warn "link $snapDate to week.0 failed";
				if ($debug) {
					print "$dir/week.0 created\n";
				}
			}
# manually creation
		} elsif (not defined $opt{p}) {
			my $num = &manual_left($dir, $all, $w, $d, $h, $at);
			if ($debug) {
				print "=> Manual snapshot for $base\n";
				print "We can create $num more manual snapshots\n";
			}
			if ($num > 0) {
				&createSnap("$dir/$snapDate", $base);
			} else {
				warn "Cannot create more snapshots, $base skipped..";
				next
			}
		}
		closedir DIR;
	} else {
		warn "Cannot open $dir, $base skipped...";
		next;
	}
}

exit;

sub check_config {
	my ($key,@a, @b, @mount, $all, $w, $d, $h, $at);

	if ((!$config{enable}) and !$ARGV[0]) { exit }
	if ($config{debug}) { $debug = 1 }

	@a = `mount -t ufs -p`;
	for (@a){
		@b = split;
		push @mount, $b[1];
	}

	my $got;
	for $key (keys %fs){

		if ($#ARGV > -1) {
			$fs{$key}{enable} = 0;
			for (@ARGV){
				if ($_ eq $key) { $fs{$key}{enable} = 1; last }
			}
		}

		next if (!$fs{$key}{enable});

		$got = 0;
		for (@mount){
			if ($key eq $_) { $got = 1; last}
		}
		unless ($got) {
			warn "Not a UFS2 filesystem, $key skipped...";
			$fs{$key}{enable} = 0; 
			next
		}
		if (! -d "$key/$fs{$key}{dir}") {
			mkdir "$key/$fs{$key}{dir}", 0775 or
			do {
				warn "Cannot mkdir $key/$fs{$key}{dir}, $key skipped...";
				$fs{$key}{enable} = 0; 
				next
			};
			system "$config{chown} root:operator $key/$fs{$key}{dir}";
			chmod 0775,"$key/$fs{$key}{dir}";
		}
		$all = $fs{$key}{all} || 20;
		$w = $fs{$key}{weekly} || 0;
		$d = $fs{$key}{daily} || 0;
		$h = $fs{$key}{hourly} || 0;
		$at = $fs{$key}{atHour};
		if ($all > 20 or $all < 0) { # filesystem limit
			warn "all > 20 or all < 0, $key skipped...";
			$fs{$key}{enable} = 0; 
			next
		}
		if ($w < 0 or $d < 0 or $h < 0 or $w > 20 or $d > 20 or $h > 20) {
			warn "incorrect rotation time setting, $key skipped...";
			$fs{$key}{enable} = 0; 
			next
		}
		if (&max_periodic($w,$d,$h,$at) > $all) {
			warn "number of periodic snapshot > all, $key skipped...";
			$fs{$key}{enable} = 0; 
			next
		}
		if ($at){ # for ${fs}{key}{atHour} extension
			$got = 0;
			for (@$at){
				if ($_ < 0 or $_ > 23 or $_ != int($_)){
					$got = 1; last
				}
			}
			if ($got) {
				warn "incorrect atHour setting, $key skipped...";
				$fs{$key}{enable} = 0; 
				next
			}
		}
	}
}

sub createSnap {
	my ($file, $fs) = @_;
	if ($debug) {
		print "Creating snapshot $file for $fs ...";
	}
	system "$config{mount} -u -o snapshot $file $fs";
	if ($debug){
		if ($?) {
			print "FAILED\n";
		} else {
			print "done\n";
		}
	}
}

sub getSnapTime {
	my $dir = shift;
	my $file = shift; # eg, hour.0

	return 0 unless -f "$dir/$file";

	my @a = stat("$dir/$file");
	my $inode = $a[1];
	my $ctime = $a[10]; # inode change time, in fact not usable.
	opendir KK, $dir;
	my @b = readdir KK or warn "Cannot readdir in getSnapTime!";
	closedir KK;
	for (@b){
		if (-f "$dir/$_"){
			@a = stat("$dir/$_");
			if ($a[1] == $inode and /(\d\d)(\d\d)(\d\d)_(\d\d):(\d\d):(\d\d)/){
				my $k = timelocal($6,$5,$4,$3,$2-1,$1+100);
				return $k;
			}
		}
	}
	if ($debug) {
		print "getSnapTime uses ctime: $ctime, may be incorrect\n";
	}
	return $ctime;
}

sub delete {
	my $dir = shift;
	my $file = shift;
	my @a = stat("$dir/$file");
	my $inode = $a[1];

	return unless -f "$dir/$file";
	if ($debug) {
		print "Unlinking snapshot $dir/$file\n";
	}
	unlink "$dir/$file";

	opendir KK, $dir;
	my @b = readdir KK or warn "Cannot readdir in delete";
	closedir KK;
	my $tf = undef;
	for (@b){
		if (-f "$dir/$_"){
			@a = stat("$dir/$_");
			if ($a[1] == $inode){
				if (/(\d\d)(\d\d)(\d\d)_(\d\d):(\d\d):(\d\d)/) {
					$tf = $_;
				} else { return }
			}
		}
	}
	if ($debug) {
		print "Also delete unused snapshot $dir/$tf\n";
	}
	unlink "$dir/$tf";
}

sub max_periodic {
	my ($w, $d, $h, $at) = @_;
	my ($num_h, $dh, $wh, $wd, $wdh);

	if ($at) { $num_h = scalar @$at } else { $num_h = 24; }
	if ($num_h == 0) { return 0 }
	$wd = ($w > int($d/7))? int($d/7) : $w;
	$wh = ($w > int($h/(7*$num_h)))? int($h/(7*$num_h)) : $w ;
	$dh = ($d > int($h/$num_h))? int($h/$num_h) : $d;
	$wdh = ($wd > $wh)? $wh : $wd;
	return $w + $d + $h - $wd - $wh - $dh + $wdh;
}

sub manual_left {
	my ($dir, $all, $w, $d, $h, $at) = @_;

	return $all - &max_periodic($w,$d,$h,$at) - (scalar &manual_files($dir));
}

sub total_inodes {
	my $dir = shift;

	opendir KK, $dir;
	my @b = readdir KK or warn "Cannot readdir in total_inodes()";
	closedir KK;

	my %inodes = ();
	my $file;
	my @a;
	for $file (@b){
		if (-f "$dir/$file") {
			@a = stat("$dir/$file");
			$inodes{$a[1]} = 1;
		}
	}
	@b = keys %inodes;
	return wantarray ? @b : ($#b + 1);
}

sub manual_files {
	my $dir = shift;

	opendir KK, $dir;
	my @b = readdir KK or warn "Cannot readdir in manaul_files";
	closedir KK;

	my @periodic = ();
	my %aperiodic = ();
	my $file;
	my @a;
	for $file (@b){
		if (-f "$dir/$file") {
			if ($file =~ /^(hour\.(\d+)|week\.(\d+)|daily\.(\d+))$/) { 
				@a = stat("$dir/$file");
				push @periodic, $a[1]; # put inode
			}
		}
	}
	for $file (@b){
		if (-f "$dir/$file") {
			@a = stat("$dir/$file");
			my $got = 0;
			for (@periodic) {
				if ($a[1] == $_) { $got = 1; last }
			}
			if (!$got) { $aperiodic{$a[1]} = $file; }
		}
	}
	@b = values %aperiodic;
	return wantarray ? @b : ($#b + 1);
}

sub show_manual_files {
	my @d = @_;
	my @dd = ();

	print "\n" if $debug;
	if ($#d == -1) {
		for (keys %fs){
			my $a = ($_ eq "/") ? "" : $_;
			push @dd, "$a/$fs{$_}{dir}" if $fs{$_}{enable};
		}
	} else {
		my $key;
		for $key (keys %fs){
			for (@d) {
				if ($key eq $_) {
					$key = "" if ($key eq "/");
					push @dd, "$key/$fs{$_}{dir}"; last
				}
			}
		}
	}

	for (sort @dd) {
		print "$_ :\n";
		my @a = &manual_files($_);
		my @b = `/bin/ls -alih $_`;
		my $k;
		for $k (sort @b) {
			for (@a) {
				if ($k =~ /\s+$_$/) {
					print $k
				}
			}
		}
	}
	print "\n";
}

sub usage {

	print <<END;

    FreeBSD UFS2 Snapshot Management Tool

Usage: $0 [-p] [-m] [-h] [-c config_file] [filesystem ...]

  -p     : periodic task, otherwise manual task.
  -m     : list manually created snapshots and exit
  -h     : show this message
  -c     : where the configuration file is

  You can specify filesystems as targets or use all
  entries in config_file.

  Notice:
  *) Filesystems specified should have corresponding 
     entries in config_file.
  *) If filesystem is specified, then it's `enable'
     flag and `atHour' will be ignored.

Description:
     3 types of periodic snapshots (hourly, daily, weekly)
  will be maintained and rotated according to settings 
  in conf_file.

  When $0 is run with -p and if it's on the hour, 
  then it will be treated as periodic task. Otherwise it 
  will be considered as a manual snapshop creation task.

Example:

 ./snap
 Do snapshot according to config_file.

 In /etc/crontab, specify
 0  *  *  *  *  root  /path-to/snap -p
 This will run snap and take 'atHour' into consideration.

 ./snap / /home /var
 Do snapshot on filesystem / and /home and /var only, 
 other setting are set as in conf_file.  This command will 
 ignore the 'enable' and 'atHour' flag in conf_file.
 If, say, /var is not in conf_file, nothing would happen.

 ./snap -m / /home
 List manual created snapshots on / and /home

 ./snap -m -c /tmp/gg/snap.conf / /home
 List manually created files of filesystem / and /home
 according to configuration /tmp/gg/snap.conf

END
	exit;
}
