#!/usr/bin/perl -w
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
use warnings;
use strict;  
use Carp;
use Config::IniFiles;
use English qw( -no_match_vars );
use File::Basename;
use Getopt::Long;
use Pod::Usage;
use MIME::Lite::TT;

#######################################################################
## Init Area
#######################################################################
$ENV{'PATH'}     = '/bin:/usr/bin:/sbin:/usr/sbin:';
$ENV{'BASH_ENV'} = '';
$ENV{'ENV'}      = '';
our $conf={
    VERSION  => '4.0',
    PROGNAME  => 'netapp-disk-usage.pl',
    config    => '/etc/netapp-disk-usage/netapp-disk-usage.ini',
    debug     => 0,
    logfile   => '/var/log/netapp-disk-usage.log',
    loglevel  => 1,
    generate_html => 0,
    html_dir      => '/srv/www/htdocs',
    html_template_dir => '/etc/netapp-disk-usage/html',
    user_mailtemplate => '/etc/netapp-disk-usage/mailtemplate.conf',
	user_mailsubject => 'Quota limit reached',
	admin_mailtemplate => '/etc/netapp-disk-usage/admin_mailtemplate.conf',
	admin_mailsubject => 'Quota limit report',
	call_resize_quota => 0,
    quota_cmd => 'quota',
	readfile_cmd => 'rdfile',
    mail_cmd  => '/usr/bin/mail',
    rsh       => '/usr/bin/ssh',
    ypmatch   => '/usr/bin/ypmatch',
    yptable   => 'passwd',
};

our @needed=qw(
    servername
    dir
    threshold
    reply
    admin
    mailadmin
    user_mailtemplate
	user_mailsubject
	admin_mailtemplate
	admin_mailsubject
    email_addr
    ignore_dirs
);

my $print_help=0;
my %results;

#######################################################################
## Function Area
#######################################################################
sub logfile {
    my ($action,$config_ref)=@_;
    if (!defined($config_ref->{'loglevel'}) || $config_ref->{'loglevel'} eq ""){
        $config_ref->{'loglevel'}=0;
    }
    if (("$action" eq "open" ) || ("$action" eq "new" )) {
        open (my $fh, ">>$config_ref->{'logfile'}") or croak "Couldn't open ".$config_ref->{'logfile'}." !\n";
        flock($fh,2) or croak "Can't get lock for $config_ref->{'logfile'} !\n";
        return $fh;
    } else {
        close $config_ref->{'logfile_fh'} or croak("Could not close filehandle $config_ref->{'logfile_fh'}\n");
    }
}

sub LOG {
    my ($message,$level,$config_ref)=@_;
    my $time = localtime(time);
    my $fh=$config_ref->{'logfile_fh'};
    if ($level <= $config_ref->{'loglevel'}){
        print $fh "$time [".$config_ref->{'PROGNAME'}."] : $message\n" or croak("Could not write to logfile\n");
    }
    debug($message);
}

sub check_ini_section($$$){
    my ($iniref,$name,$needed_ref)=@_;
    foreach my $needed (@$needed_ref){
        if (!defined($iniref->val("$name","$needed"))){
            return "$needed";
        }
    }
    return 0
}

sub cleanup_and_exit($$){
    my ($conf,$exitcode)=@_;
    if (defined($conf->{'FPADMIN'}) && ( -f "$conf->{'FPADMIN'}" )){
        unlink("$conf->{'FPADMIN'}");
    }
    logfile('close',$conf);
    exit $exitcode;
}

sub debug($){
    my ($string)=@_;
    if ($conf->{'debug'}){
        print STDERR "DEBUG: $string\n";
    }
}

# get available quota volumes 
sub get_quota_volumes($$){
	my ($conf,$servername)=@_;
	my ($user, $type, $volume, $hardlimit, %quotainfo);
	open(FPIN, "$conf->{'rsh'} $servername '$conf->{'readfile_cmd'} /etc/quotas' |") ||
		croak "Cannot execute: $conf->{'rsh'} $servername '$conf->{'readfile_cmd'} /etc/quotas' !\n";
	while(<FPIN>){
		# the expression here just tries to get the volume columns that 
		# appear in the output
		next if (/^#/);
		if(/([\S]*)[\s](user|group|tree)[\@]([\S]*)[\s]*([\d]*)[\s]*([\S]*)/){
			next if ($1 =~ /\*/);
			$user="$1";
			$type="$2";
			$volume=basename("$3");
			$hardlimit="$4";
			debug("user=$user, type=$type, volume=$volume, hardlimit=$hardlimit");
			$quotainfo{$volume}{$user}{'hardlimit'}=$hardlimit;
		}
	}
	close(FPIN);
	return \%quotainfo;
}

sub resize_quota($$$){
	my ($conf,$servername,$volume)=@_;
    debug("Calling: $conf->{'rsh'} $servername $conf->{'quota_cmd'} resize $volume |");
	open(FPIN, "$conf->{'rsh'} $servername $conf->{'quota_cmd'} resize $volume |") ||
		croak "Cannot execute: $conf->{'rsh'} $servername '$conf->{'quota_cmd'} resize $volume' !\n";
	close(FPIN);
	return;
}

#
# get_user_quotas
#  - builds the data structure of the form "maxquota:maxfile", indexed by user
#    name from the FAServer command $quota_cmd
#
# In 5.0 and later, there are colums for volume and tree names
#
sub get_user_quotas($$){
    my ($conf,$servername)=@_;
    my ($user, $usize, $hlimit, %userinfo);
    open(FPIN, "$conf->{'rsh'} $servername $conf->{'quota_cmd'} report |") || 
         croak "Cannot execute: $conf->{'rsh'} $servername $conf->{'quota_cmd'} report !\n";
    while(<FPIN>) {
        # The expression here ignores the volume and tree
        # columns that appear in the output
        if(/(user|group|tree)[\@]*[\S]*[\s]*([\S]*)[\s]*[\S]*[\s]*[\S]*[\s]*([\d]*)[\s]*([\d]*)[\s]*([\d]*)[\s]*([\d]*)[\s]*([\S\w\d\/]*)/) {
            next if ($2 =~ /\*/);
            next if ($2 =~ /0/);
            $user=$2;
            $usize=$3;
            $hlimit=$4;
            $userinfo{$user}=join(":",$usize,$hlimit);
            debug("user=$2, usize=$3, hlimit=$4");
        }
    }
    close(FPIN);
    return \%userinfo;
}

sub get_email_hash($$){
    my ($ini,$section)=@_;
    my %email=();
    if (defined($ini->val($section,'email_addr')) && $ini->val($section,'email_addr') ne ''){
      foreach my $content (split(/\s+/,$ini->val($section,'email_addr'))){
        my ($name,$email)=split /=>/,$content,2;
        $email{$name}=$email;
      }
    }
    return \%email;
}

#
# if the email address for a directory is not given, aassume the 
# dirname as the email address
#
sub get_mail_for_user($$$$){
    my ($conf,$ini,$section,$username)=@_;
    my $user_emails_ref=get_email_hash($ini,$section);
    # send email to admin, if user does not exist
    my $email=$ini->val($section,'admin');
    if (defined($user_emails_ref->{$username})){
        $email=$user_emails_ref->{$username};
    } 
    elsif (defined($conf->{'ypmatch'})) {
        my $yp_info=`$conf->{'ypmatch'} $username $conf->{'yptable'} 2>/dev/null`;
        chomp($yp_info);
        my @yp_arr = split(":",$yp_info);
        my @ignore_dirs=split(/\s+/,$ini->val($section,'ignore_dirs'));
        if (defined($ini->val($section,'ignore_dirs'))){
            if (@yp_arr && (! grep(/^$yp_arr[5]$/,@ignore_dirs))){
                $email="$yp_arr[0]";
            }
        }
    }
    return $email;
}

#######################################################################
## Main Area
#######################################################################
Getopt::Long::Configure('bundling');
GetOptions( "h|help"           => \$print_help,
            "c|config=s"       => \$conf->{'config'},
            "d|debug"          => \$conf->{'debug'},
            "logfile=s"        => \$conf->{'logfile'},
            "loglevel=i"       => \$conf->{'loglevel'},
            "k|adminonly"      => \$conf->{'adminonly'},
            "q|useronly"       => \$conf->{'useronly'},
			"r|resizequota"    => \$conf->{'call_resize_quota'},
            "generate_html"    => \$conf->{'generate_html'},
) or pod2usage(2);

pod2usage(  -exitstatus => 0,
            -verbose => 1,  # 2 to print full pod
         ) if $print_help;

my $ini = new Config::IniFiles( -file => "$conf->{'config'}",
                                -default => "global",
                                -allowcontinue => 1);

if( ! $ini ){
	croak("Could not open $conf->{'config'} : $!\n");
}

$conf->{'logfile'}=$ini->val('global','logfile') if $ini->val('global','logfile');
$conf->{'loglevel'}=$ini->val('global','loglevel') if $ini->val('global','loglevel');

my $logfile_ref=logfile('open',$conf);
$conf->{'logfile_fh'}=$logfile_ref;

if ($conf->{'debug'}){
    use Data::Dumper;
    print STDERR "Config:\n".Data::Dumper->Dump([$conf])."\n";
    print STDERR "Ini:\n".Data::Dumper->Dump([$ini])."\n";	
}

foreach my $section ( $ini->Sections() ){
    next if ( $section eq "global" );
    if ( $conf->{'generate_html'} ){
		push @needed,'html_template_file';
		push @needed, 'html_dir';
    }
    my $res=check_ini_section($ini,$section,\@needed);
	if ($res){
        LOG("Needed element $res not found in $section from $conf->{'config'} - skipping",2,$conf);
		print STDERR "Needed element $res not found in $section from $conf->{'config'} - skipping\n";
        next;
    }
    if ($conf->{'call_resize_quota'}){
			# resize quota changes to get the 'not yet commited' changes from the 
			# frontend also included
			my $quotainfo_ref=get_quota_volumes($conf,$ini->val($section,'servername'));
			foreach my $volume (sort(keys(%$quotainfo_ref))){
				debug("Resizing quota on ".$ini->val($section,'servername')." for: $volume");
				resize_quota($conf,$ini->val($section,'servername'),$volume);
			}
	}
    my $template;
    my $userinfo_ref=get_user_quotas($conf,$ini->val($section,'servername'));
    my $usercount=0;
	my @userdata;
	my @unkown_users;
	my @all_user_data;
    if ( $conf->{'generate_html'} ){
        use HTML::Template;
        LOG("Generating HTML page for $section",7,$conf);
		my $html_dir=$ini->val($section,'html_dir');
		my $html_template_file=$ini->val($section,'html_template_file');
        open(HTML,">","$html_dir/$section.html") or croak "Could not create $html_dir/$section.html: $!|n";
        $template=HTML::Template->new( filename => "$html_template_file" );
    }
    foreach my $user (sort(keys(%$userinfo_ref))){
		if ($user =~ /^-?\d+\z/){
			LOG("Please check, if $user really exists or is a zombie",7,$conf);
			push @unkown_users,$user;
		}
        my ($usize, $hlimit, $email) = split(":",$userinfo_ref->{$user});
        my $pdisk=($usize*100/$hlimit);
        if ( $conf->{'generate_html'} ){
			if ($user !~ /^-?\d+\z/){	
					my $userdetails={
										'uid'		=> $user,
										'usize'		=> $usize,
										'hlimit'	=> $hlimit,
										'pdisk'		=> $pdisk,
					};
					push @all_user_data, $userdetails;
			}
        }
        if($pdisk > $ini->val($section,'threshold')){
            # try to get the emails for each user
	        my $email=get_mail_for_user($conf,$ini,$section,$user);
    	    LOG("User $user uses $pdisk % which is over threshold (".$ini->val($section,'threshold')."%)",4,$conf);
            my %userdata=( 'username'  => $user, 
                           'useremail' => $email,
                           'usize'     => $usize,
                           'hlimit'    => $hlimit,
                           'pdisk'     => $pdisk,
                           'server'    => $ini->val($section,'servername'),
                           'dir'       => $ini->val($section,'dir'),
                           'reply'     => $ini->val($section,'reply'),
                           'treshold'  => $ini->val($section,'threshold'),
                         );
            if (! $conf->{'adminonly'}){
                LOG("User=$user, DiskUsage=$usize PercentUsed=$pdisk => sending mail to $email",3,$conf);
				my $msg = MIME::Lite::TT->new(
					From        => $ini->val($section,'reply'),
					To 			=> $userdata{'useremail'},
					Subject     => $ini->val($section,'user_mailsubject'),
					Template    => $ini->val($section,'user_mailtemplate'),
					TmplParams  => \%userdata,
				);
				$msg->send();
            }
            $usercount++;
			push @userdata,\%userdata;
        }
    }
    if (($ini->val($section,'mailadmin')) && (!$conf->{'useronly'})){
		my $unkown_users;
		if (@unkown_users){
			$unkown_users=join(", ", sort{$a<=>$b}(@unkown_users));
			LOG("Found the following unknown userids: $unkown_users",3,$conf);
		}
		my %data=(
			'date' => scalar localtime(time),
			'over_quota_count' => $usercount,
			'total_users_count' => scalar keys %$userinfo_ref,
			'userdata' => \@userdata,
			'unknown_users' => $unkown_users,
		);
		LOG("Sending summary email to ".$ini->val($section,'admin'),3,$conf);
		my $msg = MIME::Lite::TT->new(
			From        => $ini->val($section,'reply'),
			To          => $ini->val($section,'admin'),
			Subject		=> $ini->val($section,'admin_mailsubject'),
			Template    => $ini->val($section,'admin_mailtemplate'),
			TmplParams  => \%data,
		);
        $msg->send();
    }
	if ( $conf->{'generate_html'} ){
        $template->param( TITLE      => "$section",
						  DATE       => scalar localtime(time),
                          ADMIN_MAIL => $ini->val($section,'reply'),
						  USER_DATA  => \@all_user_data,
                         );
		print HTML $template->output;
		close(HTML);
	}	
}

cleanup_and_exit($conf,0);

__END__

=head1 Disk Usage for NetApp

Utility to notify NetApp users if his/her disk usage exceeds a percentage of quota.

=head1 SYNOPSIS

./netapp-disk-usage.pl [OPTIONS]

Options:

    -c <file>  | --config <file>
    -k         | --adminonly
    -q         | --useronly
	-r         | --resizequota

               | --logfile <file>
               | --loglevel <int>

    -h         | --help
    -d         | --debug

=head1 OPTIONS

=over 8

=item B<--config> F<file> | B<-c> F<file>

Use configfile F<file> instead of default /etc/netapp-disk-usage/netapp-disk-usage.ini

=item B<--adminonly> | B<-k>

Usage is not mailed to users, but only to admin.users.

=item B<--useronly> | B<-q>

Do not send email summary reports to admin users.

=item B<--resizequota> | B<-r>

Call 'quota resize <volume>' for each volume that has quotas enabled to be sure that the 
configured quotas are enabled for all users.

=item B<--logfile> F<file>

Logfile to use (Default: /var/log/netapp-disk-usage.log)

=item B<--loglevel> <int> 

Loglevel (default: 1)

=item B<--help> | B<-h>

This output.

=item B<--debug> | B<-d>

Print debugging information.

=back

=head1 DESCRIPTION

Utility to notify users if his/her disk usage exceeds a percentage of quota.
Works ONLY if the quota is from a Network Appliance's FAServer. The script 
must be invoked from a node that has remote shell rights to the filer.

If any users disk usage is over, tpercent, sent e-mail to the user. 
Also, collect all these e-mail and sent a summary to the admins at the end of the run.

It can be configured with the needed options in a global config file. 
Please have a look at the example configurations comming with the script.

Typically run once or twice a week from cron on filer admin host.

You need the following perl modules installed on your system:

=over 8

=item Carp

=item Config::IniFiles

=item Getopt::Long

=item Pod::Usage

=item Mail::Mailer

=item MIME::Lite::TT

=back

=head1 The configuration file content

We are using a basic ini style syntax here to make it really easy. Please define at least a B<[global]> section and one section for a filer.

The B<[global]> section should contain the following options:

=over 8

=item logfile=F</path/to/file>

Logfile to use (Default: /var/log/netapp-disk-usage.log)

=item loglevel=B<int>

Loglevel (default: 1) means: the verbosity of the script.

=over 2

=item 1 = errors only

=item ...

=item 7 = full debug

=back

Choose something between 3-4 for your daily business.

=back 

A B<filer> section should contain the following options (and maybe have the same name as your filer):

=over 8

=item servername=B<FQDN of your filer>

The FQDN of your filer - this name is used to execute the quota command.

=item dir=F</home>

The directory containing the quotas. This variable can also be used in your Email templates.

=item threshold=B<int>

The disk usage percentage; report is sent when users exceeds this threshold.

=item reply=B<quota-reply@example.com>

reply-to email for status mails. Mandatory

=item user_mailtemplate=F</etc/netapp-disk-usage/user_mailtemplate.conf>

Template to use for Emails sent to the users. You can use the Perl Template::Toolkit syntax here.

=item user_mailsubject=B<Disk usage report>

The subject line of the email sent to users. Please note that this has to be a one liner.

=item admin_mailtemplate=F</etc/netapp-disk-usage/admin_mailtemplate.conf>

Template to use for Emails sent to the admins (see admin option) of that filer.

=item admin_mailsubject=B<Disk usage report>

The subject line of the email sent to filer admins. Please note that this has to be a one liner.

=item admin=B<netapp-admins@example.com>

Email addresses of your filer admins separated by white space.

=item mailadmin=B<0|1>

Send summary emails to the filer admin (0=no/1=yes)

=item email_addr=B<john=>doe@mailhost.foo sally=>bar@mailhost2.com>

Normally, the 2nd column of the "quota report" is the user's account name. Emails are sent to this address by default.
But if Emails need to be sent to a different address you can map them here.

=item ignore_dirs=F</home/gone /home/update_watch>

Ignore the given directories.

=back

=head1 AUTHORS

=over 8

=item This script is originally based on the work of 
       Philip Thomas
       Motorola, SPS
       (602)655-3678
       rxjs80@email.sps.mot.com 

=item First enhancements by Thomas Siedentopf

=item Rewritten and currently maintained by Lars Vogdt

=back

=cut
 

