#!/usr/bin/env perl
#
# check_ipmi - monitoring-plugins.org compatible IPMI status plugin
#
# THIS SOFTWARE IS IN PUBLIC DOMAIN
#   There is no limitation on any kind of usage of this code. 
#   You use it because it is yours.
#
#   Originally written and maintained by Matlib (matlib@matlibhax.com),
#   any comments or suggestions are welcome.
#

my $PLUGIN = "IPMI";
use v5.10;
use strict;
use warnings;
use utf8;
use Getopt::Long
	 qw/:config posix_default auto_help bundling gnu_getopt no_ignore_case/;
binmode (*STDIN, ':utf8');
binmode (*STDOUT, ':utf8');
binmode (*STDERR, ':utf8');
utf8::decode($_) foreach (@ARGV);
my $VERSION = "6";

use POSIX;
use Scalar::Util qw/looks_like_number/;
use List::Util qw/all any first/;

my %units = (
	'degrees C' => 'C',
	'degrees F' => 'F',
	'RPM'       => '',
	'Volts'     => 'V',
);

my %options;
exit 1 unless GetOptions (\%options, 'path=s', 'sudo|s', 'retry=i',
	 'retry-interval=s', 'ipmitool', 'freeipmi', 'file|f=s', 'force|F=s',
	 'ignore|I=s', 'skip|S=s', 'power-warning|w=i', 'power-critical|c=i',
	 'version|V');
if ($options{version})
{
	print ("check_ipmi version $VERSION\n");
	exit (0);
}
die 'Switches --path, --sudo, --retry, and --retry-interval ' .
	 "cannot be used with --file\n"
	 if defined $options{file} and (defined $options{path} or $options{sudo} or
	 defined $options{retry} or defined $options{'retry-interval'});
die "Either --freeipmi or --ipmitool may be specified\n"
	 if $options{ipmitool} and $options{freeipmi};
die "Argument to --retry-interval must be a number\n"
	 if defined $options{'retry-interval'} and
	 not looks_like_number($options{'retry-interval'});
die "Power warning level (--power-warning) must be a number\n"
	 if defined $options{'power-warning'} and
	 not looks_like_number ($options{'power-warning'});
die "Power critical level (--power-critical) must be a number\n"
	 if defined $options{'power-critical'} and
	 not looks_like_number ($options{'power-critical'});
$options{path} = "$options{path}/"
	 if defined $options{path} and $options{path} !~ m/\/$/s;
$options{path} //= '';

my ($sensor, $power);
if (defined $options{file})
{
	$sensor = $power = '';
	my $f;
	status (3, "Cannot open '$options{file}': $!")
		 unless open ($f, '<', $options{file});
	my $output;
	while (readline ($f))
	{
		if (m/^--sensor--$/im)
		{
			$output = \$sensor;
			next;
		}
		elsif (m/^--power--$/im)
		{
			$output = \$power;
			next;
		}
		next if m/^\s*$/s;
		$$output .= $_ if defined $output;
	}
	close($f);
	unless ($options{ipmitool} or $options{freeipmi})
	{
		if ($sensor =~ m/^Sensor ID/im)
		{
			$options{ipmitool} //= 1;
		}
		elsif ($sensor =~ m/^ID String:/im)
		{
			$options{freeipmi} //= 1;
		}
		elsif ($sensor =~ m/[^\s]/s)
		{
			status (3,
				 "Could not determine utility type from file '$options{file}'");
		}
	}
}
elsif (not defined $options{ipmitool} and not defined $options{freeipmi})
{
	if (length ($options{path}))
	{
		if (-f "$options{path}ipmitool")
		{
			$options{ipmitool}++;
		}
		elsif (-f "$options{path}ipmi-sensors" and -f "$options{path}ipmi-dcmi")
		{
			$options{freeipmi}++;
		}
		else
		{
			die 'Cannot find IPMI binary; ' .
				 "use --freeipmi or --ipmitool to specify\n";
		}
	}
	else
	{
		eval { cmd ('which ipmitool') };
		if ($@)
		{
			eval {
				cmd ('which ipmi-sensors');
				cmd ('which ipmi-dcmi');
			};
			die 'Cannot find IPMI binary; ' .
				 "use --freeipmi or --ipmitool to specify\n"
				 if $@;
			$options{freeipmi}++;
		}
		else
		{
			$options{ipmitool}++;
		}
	}
}

my (@force, @ignore, @skip);
if (length ($options{force}))
{
	push (@force, { input => $_, regex => pattern ($_) })
		foreach (split (m/\s*,\s*/s, $options{force}));
}
if (length ($options{ignore}))
{
	push (@ignore, pattern ($_))
		 foreach (split (m/\s*,\s*/s, $options{ignore}));
}
if (length ($options{skip}))
{
	push (@skip, pattern ($_)) foreach (split (m/\s*,\s*/s, $options{skip}));
}

my ($parse_error, @sensor, @info, @warning, @critical, @perf);

if ($options{freeipmi})
{
	eval {
		$sensor = cmd (($options{sudo} ? 'sudo ' : '') .
			 "$options{path}ipmi-sensors -v", $options{retry},
			 $options{'retry-interval'})
			 unless defined $sensor;
	};
	status (3, $@) if $@;
	$sensor =~ s/^\s+|\s+$//s;

	my @patterns = (
		{name => 'value', label => 'sensor reading'                 },
		{name => 'lc',    label => 'lower critical threshold'       },
		{name => 'uc',    label => 'upper critical threshold'       },
		{name => 'lw',    label => 'lower non-critical threshold'   },
		{name => 'uw',    label => 'upper non-critical threshold'   },
		{name => 'lnr',   label => 'lower non-recoverable threshold'},
		{name => 'unr',   label => 'upper non-recoverable threshold'},
		{name => 'min',   label => 'sensor min. reading'            },
		{name => 'max',   label => 'sensor max. reading'            },
	);

	my %s = (unit => '', status => 'na');
	foreach my $l (split (m/\n/, $sensor))
	{
		$l =~ s/^\s+|\s+$//gs;
		if ($l =~ m/^record id:/i)
		{
			push (@sensor, {%s}) if defined $s{name};
			%s = (unit => '', status => 'na');
			next;
		}
		if ($l =~ m/^id string:\s*(.+)$/i)
		{
			$s{name} = $1;
			next;
		}
		if ($l =~ m/^sensor event:\s*(?:'(.+)'|(.*?))\s*$/i)
		{
			my $v = $1 // $2;
			$v =~ s/' '/, /g;
			$s{value} //= $v eq 'OK' ? '' : $v eq 'N/A' ? 'na' : $v;
			$s{unit} = 'discrete' unless length ($s{unit});
			$s{status} = $v eq 'OK' ? 'ok' : $v eq 'N/A' ? 'na' : '';
			next;
		}
		foreach (@patterns)
		{
			if ($l =~ m/^\Q$_->{label}\E:\s*/i)
			{
				substr ($l, 0, $+[0], '');
				if ($l =~ m/^(-?[0-9]+(?:\.[0-9]+)?)(?:\s+(.+))?$/)
				{
					my ($v, $u) = ($1, $2);
					$v =~ s/\.0+$//s;
					$v =~ s/0+$//s if $v =~ m/\./;
					$s{$_->{name}} = $v;
					$s{unit} = $u;
				}
				elsif ($l eq 'N/A')
				{
					$s{$_->{name}} = 'na';
				}
				else
				{
					$parse_error++;
				}
				last;
			}
		}
	}
	push (@sensor, {%s}) if defined $s{name};
}
else
{
	eval {
		$sensor = cmd (($options{sudo} ? 'sudo ' : '') .
			 "$options{path}ipmitool -v sensor", $options{retry},
			 $options{'retry-interval'})
			 unless defined $sensor;
	};
	status (3, $@) if $@;
	$sensor =~ s/^\s+|\s+$//s;

	my @patterns = (
		{name => 'lc',    label => 'lower critical'       },
		{name => 'uc',    label => 'upper critical'       },
		{name => 'lw',    label => 'lower non-critical'   },
		{name => 'uw',    label => 'upper non-critical'   },
		{name => 'lnr',   label => 'lower non-recoverable'},
		{name => 'unr',   label => 'upper non-recoverable'},
	);

	my %s = (status => 'na');
	my ($l, $v);
	foreach (split (m/\n/, $sensor))
	{
		s/^\s+|\s+$//gs;
		if (m/^(.+?)\s*:\s*(.*)$/)
		{
			$l = lc ($1);
			$v = $2;
		}
		elsif (length)
		{
			$v = $_;
		}
		else
		{
			next;
		}
		next unless defined $l;

		if ($l eq 'sensor id')
		{
			push (@sensor, {%s}) if defined $s{name};
			%s = (name => $v =~ s/\s+\(.*\)$//r, status => 'na');
			next;
		}
		if ($l eq 'sensor reading')
		{
			if ($v =~ m/^([0-9]+(?:\.[0-9]+)?)\s*(?:\(.+\))?\s*(.+)$/)
			{
				$s{value} = $1;
				$s{unit} = $units{$2};
				$s{value} =~ s/\.0+$//s;
				$s{value} =~ s/0+$//s if $v =~ m/\./;
			}
			elsif ($v =~ m/unable to read/i)
			{
				$s{value} = 'na';
				$s{unit} = '';
			}
			else
			{
				$parse_error++;
			}
			next;
		}
		if ($l eq 'status')
		{
			$s{status} = $v;
			next;
		}
		if ($l eq 'states asserted')
		{
			if ($v =~ m/^\[(.+)\]$/)
			{
				$s{value} = $1;
				$s{unit} = 'discrete';
				$s{status} = 'ok';
			}
			next;
		}
		foreach (@patterns)
		{
			next unless lc ($l) eq $_->{label};
			if ($v =~ m/^-?[0-9]+(?:\.[0-9]+)?$/)
			{
				$v =~ s/\.0+$//s;
				$v =~ s/0+$//s if $v =~ m/\./;
				$s{$_->{name}} = $v;
			}
			elsif ($v ne 'na')
			{
				$parse_error++;
			}
			last;
		}
	}
	push (@sensor, {%s}) if defined $s{name};
}

foreach my $s (@sensor) {
	foreach (@skip)
	{
		next if $s->{name} =~ $_;
	}

	my $label = $s->{name};
	$label =~ s/\stemp[a-z]+$/ Temp/is;
	$label =~ s/\s+stat[a-z]+$//is;
	$label = normalize ($label);

	if ($s->{status} eq 'na')
	{
		push (@critical, "$label: not available")
			 if any {$s->{name} =~ $_->{regex}} @force;
		next;
	}

	if (not length ($s->{unit}) and $s->{value} eq 'na')
	{
		push (@critical, "$label: not present")
			 if any {$s->{name} =~ $_->{regex}} @force;
	}

	elsif ($s->{unit} eq 'discrete')
	{
		if (not length($s->{status}) or lc ($s->{status}) eq 'ok')
		{
			if (length ($s->{value}))
			{
				if ($s->{value} =~ m/^presence detected$/is or
					 any { $s->{name} =~ $_ } @ignore)
				{
					push (@info, "$label: $s->{value}");
				}
				else
				{
					push (@warning, "$label: $s->{value}");
				}
			}
			elsif (any { $s->{name} =~ $_->{regex} } @force)
			{
				push (@critical, "$label: not present");
			}
		}
		elsif (any { $s->{name} =~ $_ } @ignore)
		{
			push (@info, "$label: " . join (length ($s->{value}) ?
				 $s->{value} : '', "[$s->{status}]"));
		}
		else
		{
			push (@critical, "$label: " . join (' ', length ($s->{value}) ?
				 $s->{value} : '', "[$s->{status}]"));
		}
	}

	else
	{
		$s->{unit} = $units{$s->{unit}} // '' if length ($s->{unit}) > 1;

		if (($s->{lw} // '') eq 'na' and ($s->{uw} // '') eq 'na' and
			 all {looks_like_number($_)}
			 ($s->{lnr}, $s->{unr}, $s->{lc}, $s->{uc}))
		{
			$s->{lw} = $s->{lc};
			$s->{lc} = $s->{lnr};
			$s->{uw} = $s->{uc};
			$s->{uc} = $s->{unr};
			$s->{lnr} = '';
			$s->{unr} = '';
		}
		$s->{lw} = '' unless looks_like_number($s->{lw});
		$s->{uw} = '' unless looks_like_number($s->{uw});
		$s->{lc} = '' unless looks_like_number($s->{lc});
		$s->{uc} = '' unless looks_like_number($s->{uc});
		$s->{lnr} = '' unless looks_like_number($s->{lnr});
		$s->{unr} = '' unless looks_like_number($s->{unr});
		push (@perf, "'" . ss ($label =~ s/'"[\000-\037]//grs, 0, 30) .
			 "'=$s->{value}$s->{unit};$s->{lw}:$s->{uw};" .
			 "$s->{lc}:$s->{uc};$s->{lnr};$s->{unr}");
		unless (any {$s->{name} =~ $_} @ignore)
		{
			if ((looks_like_number($s->{lc}) and $s->{value} < $s->{lc}) or
				 (looks_like_number($s->{uc}) and $s->{value} > $s->{uc}))
			{
				push (@critical, "$label: $s->{value} (critical: " .
					 join ('/', length ($s->{lc}) ? $s->{lc} : (),
					 length ($s->{uc}) ? $s->{uc} : ()) . ')');
			}
			elsif ((looks_like_number($s->{lw}) and $s->{value} < $s->{lw}) or
				 (looks_like_number($s->{uw}) and $s->{value} > $s->{uw}))
			{
				push (@warning, "$label: $s->{value} (warning: " .
					 join ('/', length ($s->{lw}) ? $s->{lw} : (),
					 length ($s->{uw}) ? $s->{uw} : ()) . ')');
			}
		}
	}
}

foreach my $f (@force)
{
	unless (any { $_->{name} =~ $f->{regex} } @sensor)
	{
		push (@critical, "No sensors like '" . normalize ( $f->{input} ) .
			 "' found");
	}
}

if ($parse_error)
{
	push (@warning, "$parse_error line" . ($parse_error > 1 ? 's' : '') .
		 ' could not be parsed');
}

unless (defined $power)
{
	my $cmd = $options{freeipmi} ? 'ipmi-dcmi --get-system-power-statistics' :
		 'ipmitool dcmi power reading';
	eval {
		$power = cmd (($options{sudo} ? 'sudo ' : '') .
			 "$options{path}$cmd", $options{retry},
			 $options{'retry-interval'});
	};
	status (3, $@) if $@;
}

if ($power =~ m/^\s*(?:current\ power|instantaneous\ power\ reading)[^\n]*\s
	 ([0-9]+(?:\.[0-9]+)?)(?:\s|$)/imx)
{
	my $power = $1;
	$power =~ s/\.0+$//;
	$power =~ s/0+$// if $power =~ m/\./;
	push (@perf, "'Power reading'=$power;" .
		 ($options{'power-warning'} // '') . ';' .
		 ($options{'power-critical'} // ''));
	if (defined $options{'power-critical'} and
		 $power > $options{'power-critical'})
	{
		push (@critical, "Power reading: $power W " .
			 "(critical: $options{'power-critical'} W)");
	}
	elsif (defined $options{'power-warning'} and
		 $power > $options{'power-warning'})
	{
		push (@warning, "Power reading: $power W " .
			 "(warning: $options{'power-warning'} W)");
	}
	else
	{
		unshift (@info, "Power reading: $power W");
	}
}
elsif ($power =~ m/[^\s]/s)
{
	push(@critical, 'Cannot parse power information')
}

unless (@critical + @warning + @info)
{
	status (3, 'tool returned no data') unless @sensor;
	push (@info, scalar (@sensor) . ' sensor value' . (@sensor > 1 ? 's' : '') .
		 ' normal');
}
status (@critical ? 2 : @warning ? 1 : 0,
	 join ("\n", @critical, @warning, @info) . '|' . join (' ', sort @perf));

use IPC::Open3;
use Time::HiRes;
sub cmd
{
	my ($cmd, $repeat, $interval) = @_;
	my $output;
	while () {
		my ($pid, $in, $out);
		eval { $pid = open3 ($in, $out, undef, $cmd) };
		status (3, "Cannot launch '$cmd': $@") if $@;

		local $/;
		$output = readline($out);
		close ($in);
		close ($out);
		wait();
		last unless $?;

		if (defined $repeat and $repeat-- > 0)
		{
			Time::HiRes::sleep($interval // .5);
			next;
		}
		die "$cmd " . (WIFSIGNALED ($?) ? 'killed by signal ' . WTERMSIG ($?) :
			 'exited with rc ' . WEXITSTATUS ($?)) . "\n$output";
	}
	return $output;
}
sub normalize
{
	my $str = "@_";
	$str =~ s/[\000-\037|]/_/gs;
	return $str;
}
sub pattern
{
	my $str = quotemeta (shift);
	$str =~ s/\\\*/.*/gs;
	$str =~ s/\\\?/./gs;
	return qr/^$str$/is;
}
sub ss
{
	my ($string, $offset, $length) = @_;
	return undef unless defined $string;
	return '' if $offset > length ($string);
	return defined $length ? substr ($string, $offset, $length)
		 : substr ($string, $offset);
}
sub status
{
	my ($code, $text) = @_;
	my @codes = ('OK', 'WARNING', 'CRITICAL', 'UNKNOWN');
	print ("$PLUGIN $codes[$code] - $text\n");
	exit ($code);
}


__END__

=pod

=encoding utf8

=head1 NAME

check_ipmi - monitoring-plugins.org compatible IPMI plugin

=head1 SYNOPSIS

  check_ipmi [ --path PATH ] [ -s ]
             [ --retry COUNT ] [ --retry-interval INTERVAL ]
             [ --ipmitool | --freeipmi | -f FILE ]
             [ -F PATTERN,... ] [ -I PATTERN,... ] [ -S PATTERN,... ]
             [ -w WARNING ] [ -c CRITICAL ]

This plugin reports used or available memory and triggers warning or critical
status at given marks.

=over

=item B<--path>=I<PATH>

Use given binary path to look for utilities.

=item B<--sudo>, B<-s>

Launch commands through L<sudo(8)>.

=item B<--retry>=I<COUNT>

Retry that many times if the IPMI command fails. There are no retries by
default.

=item B<--retry-interval>=I<INTERVAL>

Time interval in seconds between retries. Defaults to 0.5 seconds.

=item B<--ipmitool>

=item B<--freeipmi>

Use the given implementation, either L<ipmitool(1)> or L<freeipmi(7)>. The
program tries to autodetection by default.

=item B<--file>=I<FILE>, B<-f> I<FILE>

Read result from file instead. The file may contain two sections with output
of the chosen utility (see below).

=item B<--force>=I<PATTERN,...>, B<-F> I<PATTERN,...>

Add these patterns to the forced checks. This causes to report failure for
sensors that are not available or not present at all. Each I<PATTERN> may
contain * and ? wildcard characters.

=item B<--ignore>=I<PATTERN,...>, B<-I> I<PATTERN,...>

Ignore alerts from these sensors. Any readings outside warning and critical
range do not generate errors. Each I<PATTERN> may contain * and ? wildcard
characters.

=item B<--skip>=I<PATTERN,...>, B<-S> I<PATTERN,...>

Skip these sensors altogether so that they are not checked and not reported at
all. Each I<PATTERN> may contain * and ? wildcard characters.

=item B<--warning>=I<WARNING>, B<-w> I<WARNING>

=item B<--critical>=I<WARNING>, B<-c> I<WARNING>

Power consumption warning and critical levels. By default power is only
reported.

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

Print detailed help screen.

=item B<--version>, B<-V>

Print version information.

=back

The input file may contain two sections marked with strings C<--sensor--> and
C<--power--> followed by the relevant output. For ipmitool that's:

    --sensor--
    `ipmitool -v sensor`
    --power--
    `ipmitool dcmi power reading`

For FreeIPMI:

    --sensor--
    `ipmi-sensors -v`
    --power--
    `ipmi-dcmi --get-system-power-statistics`

=head1 AUTHOR

=over

=item

Matt Latusek L<matlib@matlibhax.com|mailto:matlib@matlibhax.com>

=back

=cut
