The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Daemon::Shutdown;

use warnings;
use strict;
use YAML::Any qw/Dump LoadFile/;
use Log::Log4perl;
use Params::Validate qw/:all/;
use File::Basename;
use IPC::Run;
use User;
use AnyEvent;
use Try::Tiny;

=head1 NAME

Daemon::Shutdown - A Shutdown Daemon

=head1 VERSION

Version 0.11

=cut

our $VERSION = '0.11';

=head1 SYNOPSIS

This is the core of the shutdown daemon script.
 
use Daemon::Shutdown;
my $sdd = Daemon::Shutdown->new( %args );
$sdd->start();

=head1 METHODS

=head2 new

Create new instance of Daemon::Shutdown

=head3 PARAMS

=over 2

=item * log_file <Str>

Path to log file

Default: /var/log/sdd.log'

=item * log_level <Str>

Logging level (from Log::Log4perl).  Valid are: DEBUG, INFO, WARN, ERROR

Default: INFO

=item * verbose 1|0

If enabled, logging info will be printed to screen as well

Default: 0

=item * test 1|0

If enabled shutdown will not actually be executed.

Default: 0

=item * sleep_before_run <Int>

Time in seconds to sleep before running the monitors.
e.g. to give the system time to boot, and not to shut down before users
have started using the freshly started system.

Default: 3600

=item * exit_after_trigger 1|0

If enabled will exit the application after a monitor has triggered.
Normally it is a moot point, because if a monitor has triggered, then a shutdown
is initialised, so the script will stop running anyway.

Default: 0

=item * monitor HASHREF

A hash of monitor definitions.  Each hash key must map to a Monitor module, and
contain a hash with the parameters for the module.

=item * use_sudo 1|0

Use sudo for shutdown
 
sudo shutdown -h now

Default: 0

=item * shutdown_binary <Str>

The full path to the shutdown binary

Default: /sbin/poweroff

=item * shutdown_args <ArrayRef>

Any args to pass to your shutdown_binary

Default: none

=item * shutdown_after_triggered_monitors <Str>

The number of monitors which need to be triggered at the same time to cause a 
shutdown. Can be a number or the word 'all'.

Default: 1

=item * timeout_for_shutdown <Int>

Seconds which the system call for shutdown should wait before timing out.

Default: 10

=back

=head3 Example (YAML formatted) configuration file
  
---
log_level: INFO
log_file: /var/log/sdd.log
shutdown_binary: /sbin/shutdown
shutdown_args:
  - -h
  - now
exit_after_trigger: 0
sleep_before_run: 30
verbose: 0
use_sudo: 0
monitor:
  hdparm:
    loop_sleep: 60
    disks: 
      - /dev/sdb
      - /dev/sdc
      - /dev/sdd

=cut

sub new {
    my $class = shift;

    my %params = @_;

    # Remove any undefined parameters from the params hash
    map { delete( $params{$_} ) if not $params{$_} } keys %params;

    # Validate the config file
    %params = validate_with(
        params => \%params,
        spec   => {
            config => {
                callbacks => {
                    'File exists' => sub { -f shift }
                },
                default => '/etc/sdd.conf',
            },
        },
        allow_extra => 1,
    );
    my $self = {};

    # Read the config file
    if ( not $params{config} ) {
        $params{config} = '/etc/sdd.conf';
    }
    if ( not -f $params{config} ) {
        die( "Config file $params{config} not found\n" );
    }
    my $file_config = LoadFile( $params{config} );
    delete( $params{config} );

    # Merge the default, config file, and passed parameters
    %params = ( %$file_config, %params );

    my @validate = map { $_, $params{$_} } keys( %params );
    %params = validate_with(
        params => \%params,
        spec   => {
            log_file => {
                default   => '/var/log/sdd.log',
                callbacks => {
                    'Log file is writable' => sub {
                        my $filepath = shift;
                        if ( -f $filepath ) {
                            return -w $filepath;
                        } else {

                            # Is directory writable
                            return -w dirname( $filepath );
                        }
                    },
                },
            },
            log_level => {
                default => 'INFO',
                regex   => qr/^(DEBUG|INFO|WARN|ERROR)$/,
            },
            verbose => {
                default => 0,
                regex   => qr/^[1|0]$/,
            },
            test => {
                default => 0,
                regex   => qr/^[1|0]$/,
            },
            sleep_before_run => {
                default => 3600,
                regex   => qr/^\d*$/,
            },
            exit_after_trigger => {
                default => 0,
                regex   => qr/^[1|0]$/,
            },
            use_sudo => {
                default => 0,
                regex   => qr/^[1|0]$/,
            },
            shutdown_binary => {
                default   => '/sbin/poweroff',
                type      => SCALAR,
                callbacks => {
                    'Shutdown binary exists' => sub {
                        -x shift();
                    },
                },
            },
            shutdown_args => {
                type     => ARRAYREF,
                optional => 1
            },
            monitor                           => { type => HASHREF, },
            shutdown_after_triggered_monitors => {
                default => 1,
                type    => SCALAR,
                regex   => qr/^(all|\d+)$/,
            },
            timeout_for_shutdown    => {
                default => 10,
                regex   => qr/^\d+$/,
            }
        },

        # A little less verbose than Carp...
        on_fail => sub { die( shift() ) },
    );

    $self->{params} = \%params;

    bless $self, $class;

    # Set up the logging
    my $log4perl_conf = sprintf 'log4perl.rootLogger = %s, Logfile', $params{log_level} || 'WARN';
    if ( $params{verbose} > 0 ) {
        $log4perl_conf .= q(, Screen
            log4perl.appender.Screen         = Log::Log4perl::Appender::Screen
            log4perl.appender.Screen.stderr  = 0
            log4perl.appender.Screen.layout   = Log::Log4perl::Layout::PatternLayout
            log4perl.appender.Screen.layout.ConversionPattern = [%d] %p %m%n
        );

    }

    $log4perl_conf .= q(
        log4perl.appender.Logfile          = Log::Log4perl::Appender::File
        log4perl.appender.Logfile.layout   = Log::Log4perl::Layout::PatternLayout
        log4perl.appender.Logfile.layout.ConversionPattern = [%d] %p %m%n
    );
    $log4perl_conf .= sprintf "log4perl.appender.Logfile.filename = %s\n", $params{log_file};

    # ... passed as a reference to init()
    Log::Log4perl::init( \$log4perl_conf );
    my $logger = Log::Log4perl->get_logger();
    $self->{logger} = $logger;

    $self->{is_root} = ( User->Login eq 'root' ? 1 : 0 );
    $self->{logger}->info( "You are " . User->Login );

    if ( not $self->{is_root} ) {
        $self->{logger}->warn( "You are not root. SDD will probably not work..." );
    }

    # Load the monitors
    my %monitors;
    foreach my $monitor_name ( keys( %{ $params{monitor} } ) ) {
        eval {
            my $monitor_package = 'Daemon::Shutdown::Monitor::' . $monitor_name;
            my $monitor_path    = 'Daemon/Shutdown/Monitor/' . $monitor_name . '.pm';
            require $monitor_path;

            $monitors{$monitor_name} = $monitor_package->new( %{ $params{monitor}->{$monitor_name} } );
        };
        if ( $@ ) {
            die( "Could not initialise monitor: $monitor_name\n$@\n" );
        }
    }
    $self->{monitors}           = \%monitors;
    $self->{triggered_monitors} = {};

    my $num_monitors = keys %monitors;
    if (   $self->{params}->{shutdown_after_triggered_monitors} eq 'all'
        || $self->{params}->{shutdown_after_triggered_monitors} > $num_monitors )
    {
        $self->{params}->{shutdown_after_triggered_monitors} = $num_monitors;
    }

    $logger->debug(
        sprintf "Will shutdown if %d of %d monitors agree",
        $self->{params}->{shutdown_after_triggered_monitors},
        $num_monitors
    );
    $self->{num_triggered_monitors} = 0;
    return $self;
}

=head2 toggle_trigger

Toggle whether a monitor wants to shutdown and, if enough agree, call shutdown

=cut

sub toggle_trigger {
    my ( $self, $monitor_name, $toggle ) = @_;
    my $logger = $self->{logger};

    if ( !defined $toggle || $toggle !~ /^0|1$/ ) {
        $logger->logdie( "Called with invalid value for toggle" );
    }

    # set/unset the toggle
    if ( $toggle and not $self->{triggered_monitors}->{$monitor_name} ) {
        $self->{triggered_monitors}->{$monitor_name} = 1;
    } elsif ( $self->{triggered_monitors}->{$monitor_name} and not $toggle ) {
        delete( $self->{triggered_monitors}->{$monitor_name} );
    } else { 
        # seen it before, do care, because maybe last attempt to shutdown failed?
    }

    # Store how many are triggered, and shutdown if limit reached
    $self->{num_triggered_monitors} = scalar keys %{ $self->{triggered_monitors} };
    $logger->debug( $self->{num_triggered_monitors} . " monitors are ready to shutdown" );
    if ( $self->{num_triggered_monitors} >= $self->{params}->{shutdown_after_triggered_monitors} ) {
        $self->shutdown();
    }
}

=head2 shutdown

Shutdown the system, if not in test mode

=cut

sub shutdown {
    my $self   = shift;
    my $logger = $self->{logger};

    $logger->info( "Shutting down" );

    if ( $self->{params}->{test} ) {
        $logger->info( "Not really shutting down because running in test mode" );
    } else {

        # Do the actual shutdown
        my @cmd = ( $self->{params}->{shutdown_binary} );

        # have any args?
        if ( $self->{params}->{shutdown_args} ) {
            push @cmd, @{ $self->{params}->{shutdown_args} };
        }

        if ( $self->{params}->{use_sudo} ) {
            unshift( @cmd, 'sudo' );
        }
        $logger->debug( "Shutting down with cmd: " . join( ' ', @cmd ) );

        # Sometimes the shutdown call can timeout (system unresponsive?). In this case, don't
        # die, and also don't exit_after_trigger - allow the trigger to hit again, and try again.
        try {
            my ( $in, $out, $err );
            IPC::Run::run( \@cmd, \$in, \$out, \$err, IPC::Run::timeout( $self->{params}->{timeout_for_shutdown} ) );
            if ( $err ) {
                $logger->error( "Could not shutdown: $err" );
            }
            if ( $self->{params}->{exit_after_trigger} ) {
                exit;
            }
        }
        catch {
            $logger->error( "Shutdown command failed '" . join( ' ', @cmd ) . "': $_" );
        };
    }
}

=head2 start

Start the shutdown daemon

=cut

sub start {
    my $self   = shift;
    my $logger = $self->{logger};

    $logger->info( "Started" );

    $logger->info( "Sleeping $self->{params}->{sleep_before_run} seconds before starting monitoring" );

    sleep( $self->{params}->{sleep_before_run} );

    # set up timers then wait forever
    foreach my $monitor_name ( keys %{ $self->{monitors} } ) {
        my $monitor = $self->{monitors}->{$monitor_name};

        $logger->debug( "Setting timer for monitor $monitor_name: $monitor->{params}->{loop_sleep} seconds" );
        $monitor->{timer} = AnyEvent->timer(
            after    => 0,
            interval => $monitor->{params}->{loop_sleep},
            cb       => sub {
                if ( $monitor->run() ) {
                    $self->toggle_trigger( $monitor_name, 1 );
                } else {
                    $self->toggle_trigger( $monitor_name, 0 );
                }
            }
        );
    }
    $logger->debug( 'Entering main listen loop using ' . $AnyEvent::MODEL );
    AnyEvent::CondVar->recv;

}

=head1 AUTHOR

Robin Clarke, C<< <perl at robinclarke.net> >>

=head1 BUGS

Please report any bugs or feature requests to L<https://github.com/robin13/sdd>

=head1 SUPPORT

You can find documentation for this module with the perldoc command.

    perldoc Daemon::Shutdown


You can also look for information at:

=over 4

=item * Github

L<https://github.com/robin13/sdd>

=item * Search CPAN

L<http://search.cpan.org/dist/Daemon/Shutdown/>

=back


=head1 ACKNOWLEDGEMENTS


=head1 LICENSE AND COPYRIGHT

Copyright 2011 Robin Clarke.

This program is free software; you can redistribute it and/or modify it
under the terms of either: the GNU General Public License as published
by the Free Software Foundation; or the Artistic License.

See http://dev.perl.org/licenses/ for more information.


=cut

1;    # End of Daemon::Shutdown