#!/usr/bin/perl -w

##################################################################
# (C) Copyright 2011-2014 Hewlett-Packard Development Company, L.P.
# @(#) Serviceguard cluster file comparison command
# @(#) Product Name : HP Serviceguard
# @(#) Product Version : A.12.10.00
# @(#) Patch Name :     
##################################################################

use strict;
use Carp;
use Getopt::Long qw(GetOptions HelpMessage
                    :config no_ignore_case posix_default);
use Pod::Usage;
use Data::Dumper;
# POSIX exports a ton of stuff. Suppress all but what we'll use.
use POSIX qw(pathconf _PC_PATH_MAX _PC_NAME_MAX);

=head1 NAME

cmcompare - compare each specified file across the specified set of nodes,
            or across all nodes in the cluster

=cut

=head1 SYNOPSIS

S<cmcompare
[-v]
[-n node]...
[-m content|owner|perm|time]...
[file ...]>

=cut

# global variables
our $fh;
my $sgSbin;
my $bin; 
my $errorNodeList;
&setPath;

# only root can run this
unless ($< == 0) {
    die "Only root is allowed to run $0\n";
}

my $progName = $0;

sub usage {
    my ($message) = @_;

    if (!$fh) {
        open($fh, ">&STDOUT");
    }

    print $fh "$message\n\n" if $message;
    pod2usage(-exitval => "NOEXIT", -output => $fh);
    print $fh "   -n node_name                perform checks across the " .
        "specified set of nodes\n";
    print $fh "   -m content|owner|perm|time  check for mismatches ".
        "in file content, ownership, permission, or modification timestamp\n";
    print $fh "   -v                          verbose output\n";
    print $fh "\n";
    print $fh " Compares each specified file across the specified set of nodes, " .
        "or across the nodes in the local cluster.\n";
    print $fh " If -m is not specified, all checks are performed.\n";
    print $fh " If -n is not specified, checks are performed across " .
        "all nodes in the local cluster.\n";
    print $fh " Timestamp checks are performed when other differences are " .
        "noted, regardless of whether time is specified with -m.\n";
    print $fh " Note that content checks of large files can take a long time.\n";
    exit 1;
}

sub getSGConfValue {
    my($var) = @_;

    my $sgConfFile = "/etc/cmcluster.conf";
    open (MYCONFFILE,$sgConfFile) or
          die "Failed to open $sgConfFile";

     my (@lines) = <MYCONFFILE>;
     foreach my $line (@lines) {
             next if ($line =~ /^#/); # skip comments
             next if ($line =~/^\s*$/); # skip blank lines
             my ($name, $value) = $line =~/^s*(\w+)=(\S.*)$/;
             if ($name eq $var) {
                 return($value);
             }
     }
     return("");
}

sub setPath {
    $sgSbin = getSGConfValue("SGSBIN");
    if ($^O eq "hpux") {      
        $bin = "/usr/bin";
    } elsif ($^O eq "linux") {
        $bin = "/bin";
    }
}
    
my @defaultMatches = ("content", "owner", "perm");

my $dbgLevel = 0;

sub debug {
    my ($level, $msg) = @_;
    if ($level <= $dbgLevel) {
        print $fh "$msg";
    }
}

my %cksum;
my %llOut;
my @fileNames;
my @nodeNames = ();
my $verbose = 0;
my $logPrefix = "";
my $logWarnings = 0;
my $logErrors = 0;
my $ownerCheck = 0;
my $permCheck = 0;
my $timeCheck = 0;
my $contentCheck = 0;
my $exitStatus = 0;
my $logFile = "";
my $illegalChars = '+"\'%^*()[\]\?';



sub warning {
    my ($msg) =@_;
    if ($logFile) {
        print $fh "$logPrefix" if $logWarnings or $logErrors;
        print $fh " $msg\n";
    } 
    else {
        if ($logWarnings) {
            print $fh "$logPrefix";
            print $fh " $msg\n";
        } else {
            print STDERR "$logPrefix";
            print STDERR " $msg\n";
        }
    }
}

sub readFileNames {
    print $fh "Input pathnames to compare:\n" if (-t STDIN);
    my $line;
    while ($line = <STDIN>) {
        debug 2, $line;
        chomp $line;
        next if ($line =~ /^\s*\#/);         # skip comment lines 
        # spaces delimit files, but '\ ' may be used for filenames with spaces
        $line =~ s/\\ /%20/g;                # changed escaped blanks to %20 
        $line =~ s/\s+#.*$//;                # remove embedded comments 
        next if ($line =~ /^\s*$/);          # skip blank lines 
        push(@fileNames, "$_") foreach split ' ', $line;
        # once put in the fileNames list, '\ ' can be just ' '
        s/%20/ /g foreach (@fileNames);   # restore %20 to blank
    }
}

sub getClusterNodes {
    my @output = `$sgSbin/cmviewcl -v -f line -l node | grep "\|os_status="`;
    foreach my $line (@output) {
        if ($line =~ /^node:(\S+)\|os_status=(\S+)\s*$/) {
            debug 2, "Found node $1 ($2)\n";
            my $name = $1;
            push(@nodeNames, $name) if ($2 =~ "up");
        }
        else {
            debug 1, "Skipped $line";
        }
    }
    @nodeNames = sort @nodeNames;
}

sub clrun {
    my ($cmd, $returnRet) = @_;
    my %output;
    my $retVal = 0;
    foreach my $node (@nodeNames) {
        my $out = `$sgSbin/cmexec $node "$cmd" 2>&1`;
        my $ret = $?;
        $retVal = $ret if ($ret != 0);
        debug 2, "$sgSbin/cmexec $node $cmd returned $ret:\n$out";
        if ($returnRet) {
            $output{$node} = $ret;
        }
        else {
            $output{$node} = $out;
        }
    }
    debug 2, "$cmd:\n" . Dumper(%output);
    return $retVal, %output;
}

# For commands which take file names as the final arguments, and produce
# a single line of output with the filename at the end of each line
sub gatherData {
    my ($cmdPrefix) = @_;
    my %out;

    my $cmd = "$cmdPrefix '" . join("' '", @fileNames) . "'"; 
    debug 2, "Running: $cmd\n";
    my ($runRet, %output) = clrun "$cmd";
    debug 3, "$cmdPrefix @fileNames:\n" . Dumper(%output);
    $exitStatus = 2 if $runRet;

    foreach my $node (@nodeNames) {
        my @lines = split /\n/, $output{$node};
        debug 2, "lines:\n" . Dumper(@lines);
        foreach my $file (@fileNames) {
            foreach my $line (@lines) {
                # ... /etc/hosts
                # ... /etc/hosts ...
                # ... /etc/hosts: ...
                if ($line =~ /^(?:\s*|.*\s+)$file(?:[\s:,].*|\s*)$/) {
                    $out{$file}{$node} = $line;
                    last; # go to next file
                }
            }
            $out{$file}{$node} = "<error>" if !$out{$file}{$node};
        }
    }

    debug 2, "$cmdPrefix:\n" . Dumper(%out);
    return %out;
}

sub isDir {
    my ($file, $node) = @_;
    confess "no ls data" unless $llOut{$file}{$node};
    return 1 if $llOut{$file}{$node} =~ /^d.*/;
    return 0;
}

sub getResultString {
    my ($op, $fileName, $nodeName) = @_;
    my $match;
    my $error;
    debug 2, "Getting $op result\n";
    if ($op =~ "checksum") {
        # checksumming directories always gives different results
        if (isDir($fileName, $nodeName)) {
            $match = "<directory>";
        }
        # 577800401 52705 /etc/hosts
        elsif ($cksum{$fileName}{$nodeName} =~ /^([0-9]+ [0-9]+)\s+$fileName$/) {
            $match = $1;
        }
        else {
            $error = $cksum{$fileName}{$nodeName};
        }
    }
    elsif ($op =~ "permissions") {
        # -r--r--r--   1 bin        bin          52705 May 28 10:16 /etc/hosts
        if ($llOut{$fileName}{$nodeName} =~ /^(\S+)\s+.*$fileName$/) {
            $match = $1;
        }
        else {
            $error = $llOut{$fileName}{$nodeName};
        }
    }
    elsif ($op =~ "owner") {
        # -r--r--r--   1 bin        bin          52705 May 28 10:16 /etc/hosts
        if ($llOut{$fileName}{$nodeName} =~
            /^\S+\s+\S+\s+(\S+)\s+(\S+)\s+.*$fileName$/) {
            $match = "$1/$2";
        }
        else {
            $error = $llOut{$fileName}{$nodeName};
        }
    }
    elsif ($op =~ "timestamp") {
        # -r--r--r--   1 bin        bin          52705 May 28 10:16 /etc/hosts
        if ($llOut{$fileName}{$nodeName} =~
            /^\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(\S+\s+\S+\s+\S+).*$fileName$/) {
            $match = "$1";
        }
        else {
            $error = $llOut{$fileName}{$nodeName};
        }
    }
    else {
        confess "unknown operation";
    }
    $match = "<error>" if !$match;

    return ($match, $error);
}

sub checkResults {
    my ($op, $fileName) = @_;
    my %resultNodes;
    my %errorNodes;
    # group nodes with the same result
    foreach my $nodeName (@nodeNames) {
        my ($result, $error) = getResultString($op, $fileName, $nodeName);
        if ($result =~ "<error>") {
            if ($errorNodes{$error}) {
                $errorNodes{$error} .= " $nodeName";
            }
            else {
                $errorNodes{$error} = $nodeName;
            }
        }
        elsif ($resultNodes{$result}) {
            $resultNodes{$result} .= " $nodeName";
        }
        else {
            $resultNodes{$result} = $nodeName;
        }
        debug 2, "result: $result\n";
    }
    debug 2, "keys resultNodes: " . (keys %resultNodes) . "\n";
    
    foreach my $error (keys %errorNodes) {
        warning("$fileName $op could not be checked on node\(s\) " .
                $errorNodes{$error});
        $errorNodeList = $errorNodes{$error};
        return 2;
    }
    if ((1 != keys %resultNodes) || (0 != keys %errorNodes)) {
        my $first = 1;
        my $msgString = "";
        foreach my $result (keys %resultNodes) {
            if ($first) {
                if ($op =~ /checksum/) {
                    $msgString = "$fileName has different content on node\(s\) " .
                        $resultNodes{$result};
                }
                else {
                    $msgString = "$fileName has $op \"$result\" on node\(s\) " .
                        $resultNodes{$result};
                }
                $first = 0;
            }
            else {
                if ($op =~ /checksum/) {
                    $msgString = $msgString. ", than on node\(s\) " . $resultNodes{$result};
                }
                else {
                    $msgString = $msgString. ", \"$result\" on node\(s\) " . $resultNodes{$result};
                }
            }
        }
        warning($msgString);    
    }
    debug 2, "$fileName has $op difference\n" if (1 != keys %resultNodes);
    debug 2, "$fileName has $op error\n" if (0 != keys %errorNodes);
    return (1 != (keys %resultNodes) + (keys %errorNodes));
}

# main

my @matches = ();

# -W and -E are internal, undocumented options
GetOptions("n=s" => \@nodeNames,
           "m=s" => \@matches,
           "E" => \$logErrors,
           "W" => \$logWarnings,
           "v" => \$verbose,
           "D=i" => \$dbgLevel,
           "O=s" => \$logFile) || usage();

usage("Cannot use both -E and -W options") if $logErrors and $logWarnings;
$logPrefix = "WARNING:" if $logWarnings;
$logPrefix = "ERROR:" if $logErrors;

if ($logFile) {
    open($fh, ">>$logFile") || die "Cannot open $logFile";
} 
else {
    open($fh, ">&STDOUT");
}

if (@matches == 0) {
    @matches = @defaultMatches;
    debug 2, "No checking specified, defaulting all checks except timestamp\n";
}

foreach (@matches) {
    if (/^content/) {$contentCheck = 1; next}
    if (/^owner/) {$ownerCheck = 1; next}
    if (/^perm/) {$permCheck = 1; next}
    if (/^time/) {$timeCheck = 1; next}
    usage("Unknown -m option \"$_\"");
}

getClusterNodes() if (@nodeNames == 0);
usage("No local cluster exists.  Specify nodes with -n\n") if (@nodeNames == 0);

@fileNames = @ARGV if (@ARGV > 0);
readFileNames() if (@fileNames == 0);
my @tmpFiles = (); 
foreach my $file (@fileNames) {
    if ($file =~ /([$illegalChars])/) {
        print $fh "Ignoring file $file: contains unsupported character ($1)\n";
        $exitStatus = 2;
    } elsif (-d $file) {
        print $fh "Skipping the check for $file: it is a directory. \n";
        $exitStatus = 2;       
    } else {
        push(@tmpFiles, $file);
    }
}
@fileNames = @tmpFiles;
usage("No files to compare") if (@fileNames == 0);

debug 2, "nodes        @nodeNames\n";
debug 2, "contentCheck $contentCheck\n";
debug 2, "ownerCheck   $ownerCheck\n";
debug 2, "permCheck    $permCheck\n";
debug 2, "timeCheck    $timeCheck\n";
debug 2, "verbose      $verbose\n";
debug 2, "files        @fileNames\n";

# Always get ls data since we don't use checksum of directories
%llOut = gatherData("$bin/ls -lLd");
%cksum = gatherData("/usr/bin/cksum") if $contentCheck;

debug 2, "cksum:\n" . Dumper(%cksum);
debug 2, "llOut:\n" . Dumper(%llOut);

my @diffFiles = ();
foreach my $file (@fileNames) {
    my $fileResult = 0;
    my $result = 0;
    $result = checkResults("permissions", $file) if $permCheck;
    $fileResult = 1 if ($result == 1);
    $fileResult = 2 if ($result == 2);
    $result = checkResults("owner", $file) if $ownerCheck;
    $fileResult = 1 if ($result == 1);
    $fileResult = 2 if ($result == 2);
    $result = checkResults("checksum", $file) if $contentCheck;
    $fileResult = 1 if ($result == 1);
    $fileResult = 2 if ($result == 2);
    $result = checkResults("timestamp", $file) if $timeCheck;
    $fileResult = 1 if ($result == 1);
    $fileResult = 2 if ($result == 2);
    if ($fileResult == 1) {
        checkResults("timestamp", $file);
        warning ("$file is different across nodes @nodeNames")
            if $verbose or $logWarnings or $logErrors;
        $exitStatus = 1 if $exitStatus == 0;
        push(@diffFiles, $file);
    }  
    elsif ($fileResult == 2) {
        warning("$file could not be checked on nodes $errorNodeList")
            if $verbose or $logWarnings or $logErrors;
        $exitStatus = 1 if $exitStatus == 0;
    }
    else {
        print $fh "$file is the same across nodes @nodeNames\n" if $verbose;
    }
}

print $fh "All files were the same on all nodes\n" if ($verbose && !$exitStatus);

exit $exitStatus
