#!/usr/bin/perl
##############################################################################################
#
# File:         esx_patcher
# Description:  Patch a VMWare ESX 3.0.X box from a LinuxCOE waystation
# Author:       Lee Mayes   ( email leem@hp.com )
# Created:      11 Sep 2007
# Language:     perl
# Package:      LinuxCOE
#
##############################################################################################
# © Copyright 2000-2009 Hewlett-Packard Development Company, L.P
#
# This program is free software; you can redistribute it and/or modify it under the terms of
# the GNU General Public License as published by the Free Software Foundation; either version
# 2 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with this program;
# if not, write to the Free Software Foundation, Inc.,
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.
##############################################################################################
# Changelog:  v0.0 - http support only/no CLI - first cut
#             v0.1 - add command line option support
# BUGS:       No FTP support
##############################################################################################
use strict;
use LWP::Simple;
#use Net::FTP;
use Getopt::Std;
use Digest::MD5;

# Stuff you can override on command line 
my $method = 'http';					# Default method to use (http/ftp/local -  only http supported now)
my $ftp_user = 'ftp';					# Default ftp users
my $ftp_pass = 'esx_patcher';				# Default ftp passwd
my $waystation = 'linuxcoe.corp.hp.com';		# Default place to pull from (can append :PORT# if needed)
my $path = '/LinuxCOE/VMWare-updates/ESX';		# Default path to look for patches
my $alarm_time = 600;					# Seconds before declaring myself hung
my $reboot = 0;						# Do not reboot the box when done
my $maint_ok = 0;					# Put in maint mode without prompting?
my $tweak_ok = 0;					# Tweak firewall without prompting?
my $reboot_ok = 0;					# Reboot without prompting?
my $proxy = undef;					# Use a proxy for wget?
my $local_store = '/var/updates';			# Where do I cache the files as I install 'em?
my $debug = 1;						# Debug verbosity  - 0 is TERSE, 1 is normal, 2 is chatty, 3 is insane
#my $mail = 'root@localhost';				# Mail report here, unset means no mail

# Internal stuff
my $alarm_msg;						# What to toss on alarm call
my $logfile = '>/var/log/LinuxCOE-esx_patcher.log';	# Where to log what I did (leading > means append)
my $revision = '0.1';					# Revision of this script
my %bundles;						# Where to store megapatch info

&process_options();					# How were we called?

# Initialize/reality check
$SIG{'ALRM'} = 'handler';				# Catch alarm calls
&got_root;						# Must be superuser
open(LOG,">$logfile") || die "Cannot open $logfile for writing : $!\nExiting....\n";
select((select(LOG),$|=1)[0]);  			# make LOG hot

# What ESX version are we?
my $version = `/bin/rpm --queryformat '%{VERSION}' -q VMware-esx`;
&debug('1',"\nLinuxCOE ESX patcher v$revision - running on ESX v$version\n");
unless ( $version =~ /^3.0/ ) {				# Reality Check
  &end_it_now("ESX reports $version, I only support 3.0.X to my knowledge.  Stopping before I do any harm!\nExiting...\n");
}

#####################
####### MAIN ########
#####################

# Open Firewall
my $opened = &tweak_firewall('open');			# Allow outbound connections if needed

# Fetch the patchfile
&get_file("patches.txt","$local_store/patches.txt");

# Parse the patchlist, populate %patch_db
my %patch_db = parse_filelist("$local_store/patches.txt");		
my $total = keys(%patch_db);
my $bund = keys(%bundles);

# Build a list of installed patches 
my %installed = &query_installed;
my $installed = keys(%installed);

&debug(1,"Found $total simple and $bund patch bundle on patch server, we currently have $installed simple patches installed.\n Comparing the lists.");

# Bounce the 2 lists off of each other....
my @need_em = &patch_compare;
unless (@need_em) {
  &debug(1,"All up to date!  nothing to be done....");
  exit;
}

# BUG: Report and exit if requested - future

# The game is afoot
my $mm_ent = &maint_mode('enter');			# Put in maint mode if needed

# Install the ones we need
while ( my $order = shift(@need_em )) {
  &install_patch($version,$order);
}

&tweak_firewall('block') if $opened;			# If we opened it, close it
&maint_mode('exit') if $mm_ent;				# If we entered it, exit it
&reboot_host;						# Tigger time


#############################
####### END OF MAIN #########
#############################

sub install_patch {

# Let's install a patch

  my ($version,$order) = @_;
  my $patch_id = $patch_db{$order}{id};
  my $patch_file = "${patch_id}.tgz";

# Fetch it

  $alarm_msg="Fetching $patch_id ($patch_db{$order}{size}) = $patch_db{$order}{descr}";
  alarm($alarm_time);
  &debug(1,"Fetching $patch_id ($patch_db{$order}{size}) = $patch_db{$order}{descr}");
  my $url = "${method}://$waystation/$path/$version/${patch_id}.tgz";
  my $rc = getstore($url,"$local_store/$patch_file");
  &debug(2,"Snagging $url -> $patch_file, LWP::Simple returned $rc");
  if ( $rc ne '200' ) {
    &end_it_now("Failed to fetch patchlist, see $logfile for details.\nExiting...\n");
  }
  &reset_alarm;

# Validate it

  unless (&check_sum("$local_store/$patch_file",$patch_db{$order}{md5sum})) {
    &end_it_now("$patch_file checksum failed, outta here!");
  }

# Expand it

  $alarm_msg="Expanding $patch_id ($patch_db{$order}{size}) = $patch_db{$order}{descr}";
  alarm($alarm_time);
  my $sys = "tar -C $local_store -zxf $local_store/$patch_file";
  &debug(2,"Extacting $patch_id - install order $order\n");
  &my_sys($sys);
  &reset_alarm;

# Install it

  $alarm_msg="Installing $patch_id ($patch_db{$order}{size}) = $patch_db{$order}{descr}";
  alarm($alarm_time);
  &debug(1,"Installing $patch_id....");
  if ( $bundles{$patch_id} ) {
    my ($install,@args) = split('::',$bundles{$patch_id}{installer});
    my $installer = "$local_store/$patch_id/$install";
    &debug(3,"Installing patch bundle $patch_id with $installer");
    my $pid = open(INSTALL,"| $installer >/tmp/esx_bundle_installer 2>&1");
    foreach my $arg (@args) {
      &debug(3,"Sending arg $arg");
      print INSTALL "$arg\n";
    }
    &debug(3,"Got the following output from installer:");
    open(INSTIN,"/tmp/esx_bundle_installer");
    while ( <INSTIN> ) { chomp; &debug(3,$_); }
    close(INSTIN);
    close(INSTALL);
    wait;
    my @patches = split(' ',$bundles{$patch_id}{patchlist});
    foreach my $subpatch (@patches) {
      unless ( &validate_patch($subpatch) ) {
        &end_it_now("Patch $subpatch, which is part of megapatch $patch_id failed to install.  Exiting...");
      }
    }
    &debug(1," patch bundle $patch_id successfully installed");
  } else {
    $sys = "/usr/sbin/esxupdate -n -r file:${local_store}/$patch_id update";
    if ( $debug > 2 ) {
      &my_sys($sys);
    } else {
      $sys .= " >/tmp/update$$ 2>&1";
      my $ret = system($sys);
      open(IN,"/tmp/update$$");
      while(<IN>) { chomp; &debug(3,$_) }
      close(IN);
      unlink "/tmp/update$$";
      if ( $ret ) {
        my $ec = $ret/256;
        &debug(0,"$sys returned error code $ec, exiting!");
        &end_it_now;
      }
    }
    unless ( &validate_patch($patch_id) ) {
      &end_it_now("Patch $patch_id failed to install.  Exiting...");
    }
    &debug(1," patch $patch_id successfully installed");
  }
  &reset_alarm;

# Hide the body

  unlink "$local_store/$patch_file";
  &my_sys("rm -rf $local_store/$patch_id");

}

sub reboot_host {
 
    &prompt_luser("reboot the ESX server") unless ($reboot_ok);
    &debug(1,"Rebooting ESX Server");
    my $sys = "/sbin/reboot";
    &my_sys($sys);

}

sub validate_patch {

# Ensure a patch got installed

  my $patch_id = shift;
  my $query = '/usr/sbin/esxupdate query';
  my $hit = 0;
  &debug(3,"validate_patch checking $patch_id with $query");
  open(QUERY,"$query |");
  while(<QUERY>) {
    $hit = 1 if /$patch_id/;
  }
  &debug(3,"  returning $hit (1 is OK)");
  close(QUERY);
  wait;
  return($hit);

}

sub patch_compare {

# Compare full database to installed, return delta in @array based on install order

  my @need_em;
  foreach my $patch (sort(keys(%patch_db))) {
    my $patch_id = $patch_db{$patch}{id};
    &debug(3,"Looking at $patch_id, order is $patch");
    if ( $bundles{$patch_id} ) {
      &debug(3,"Patch $patch_id is a bundle, looking for subset");
      my @patches = split(' ',$bundles{$patch_id}{patchlist});
      foreach my $subpatch (@patches) {
        unless ($installed{$subpatch})  { push(@need_em,$patch);  &debug(3,"Need $patch_id, will install"); }
      }
    } elsif ($installed{$patch_id}) {
      &debug(3,"Already have $patch_id"); 
    } else {
      &debug(3,"Need $patch_id, will install");
      push(@need_em,$patch);
    }
  }
  return(@need_em);

}

sub prompt_luser {

  my $msg = shift;  
  &debug(0,"\nI need to $msg\nPress return and I'll do it, or press <cntl-c> or wait 15 seconds to exit as is.\nSee the -y flag to skip this message in the future");
  $alarm_msg="User did not give permission to $msg within timeout";
  alarm(15);
  <STDIN>;
  &reset_alarm;

}

sub maint_mode {

# Handle Maint Mode
  my $cmd = shift;
  my $vimsh = '/usr/bin/vimsh';
  if ( $cmd eq 'enter' ) {
    my $maint_now = &maint_mode;
    return 0 if ($maint_now);				# Already in Maint Mode
# Enter Maint mode
    &prompt_luser("put the ESX server in Maintenance Mode") unless ($maint_ok);
    my $sys = "$vimsh -n -e /hostsvc/maintenance_mode_enter";
    &debug(2,"Entering MaintMode with $sys");
    my $rc = `$sys`;
    &debug(3,$rc);
    &debug(1,"ESX server now in Maintenance Mode");
    return 1;
  } elsif ( $cmd eq 'exit' ) {
# Exit Maint mode
    return unless &maint_mode;
    my $sys = "$vimsh -n -e /hostsvc/maintenance_mode_exit";
    &debug(2,"Exiting MaintMode with $sys");
    my $rc = `$sys`;
    &debug(3,$rc);
    &debug(1,"ESX server returned from Maintenance Mode");
  } else {
# Query Maint Mode - return true/false based on status
#   inMaintenanceMode = true,  || false,
    my $sys = "$vimsh -n -e hostsvc/runtimeinfo | grep inMaintenanceMode ";
    my $state = `$sys`;
    &debug(3,"Checking status of Maint Mode with $sys : $state");
    return 1 if ( $state =~ /true/ );
    return 0;
  }

}


sub query_installed {

# Build a simple hash of installed patches

  my $query = '/usr/sbin/esxupdate query';
  $alarm_msg = "Calling esxupdate query for installed patch list";
  alarm(60);			# give it a minute
  &debug(2,"Calling $query for a list of installed patches");
  unless (open(QUERY,"$query |")) {
    &end_it_now("Cannot fork esxupdate query : $!\nExiting....");
  } 
  my %installed;
  while(<QUERY>) {
    chomp;
    my ($id,$time,$date,@rest) = split;
    next unless ( $id =~ /^ESX-/ );
    my $desc = join(' ',@rest);
    $installed{$id} = 1;
    &debug(3," found installed $id - $desc");
  }
  close(QUERY);   
  wait;
  &reset_alarm;
  return(%installed);

}

sub parse_filelist {

# Read the list of patches, populate a hash with datat (id/descr/md5sum)
  unless ( open(IN,"$local_store/patches.txt") ) {
    &end_it_now("Cannot open patchlist ($local_store/patches.txt) for reading : $!\nExiting...");
  }
  my %db;
  while(<IN>) {
    chomp;
    next unless $_;	# Skip blank lines 
    next if /^#/;	# No comment
    if ( /^BUNDLE/ ) {
      my ($info,$id,$install_str,$patch_list) = split(':::',$_);
      $bundles{$id}{patchlist} = $patch_list;
      $bundles{$id}{installer} = $install_str;
      &debug(3,"Found patch bundles $id consising of:\n $patch_list");
      next;
    }
    my ($order,$id,$type,$md5sum,$size,@rest) = split(',',$_);
    my $descr = join(',',@rest);
    $db{$order}{id} = $id;
    $db{$order}{md5sum} = $md5sum;
    $db{$order}{descr} = $descr;
    $db{$order}{type} = $type;
    $db{$order}{size} = $size;
    &debug(3,"Found patch $id, install order $order");
  }
  close(IN);
  return(%db);

}

sub check_sum {

# Check sum against expected
  my ($file,$sum) = @_;
  my $ctx = Digest::MD5->new;
  open(MD5FILE,$file);
  $ctx->addfile(*MD5FILE);
  my $digest = $ctx->hexdigest;
  close(MD5FILE);
  &debug('3',"$file has sum of \n>$digest< I expected \n>$sum<\n");
  if ( $digest eq $sum ) { return 1 }
  return 0;

}

sub get_file {

# Fetch a file from the remote server
  my ($url,$file) = @_;
  unless ( -d "$local_store" ) { &my_sys("/bin/mkdir -p $local_store") }
  if ( $method eq 'http' ) {
    &debug(1,"Fetching patch list from waystaion");
    my $rc = getstore("http://$waystation/$path/$version/$url",$file);
    &debug(2,"Snagging http://$waystation/$path/$version/$url -> $file, LWP::Simple returned $rc");
    if ( $rc ne '200' ) {
      &end_it_now("Failed to fetch patchlist, see $logfile for details.\nExiting...\n");
    }
  } elsif ( $method eq 'ftp' ) {
  }

}

sub tweak_firewall {

# [root@d3tesxbl1 root]# esxcfg-firewall --q Outgoing
# Outgoing ports not blocked by default.
# [root@d3tesxbl1 root]# esxcfg-firewall --blockOutgoing
# 2007-09-12 02:09:35 (30463) WARN :  Setting firewall default /firewall/blockOutgoing to 1
# [root@d3tesxbl1 root]# esxcfg-firewall --q Outgoing
# Outgoing ports blocked by default.
#
  my $cmd = shift;
  my $fwcfg = '/usr/sbin/esxcfg-firewall';
  if ( $cmd eq 'block' ) {
    my $sys = "$fwcfg --blockOutgoing";
    &debug(1,"Resetting firewall to block Outgoing connections");
    &debug(3,"Calling $fwcfg --blockOutgoing");
    &my_sys($sys);
    return undef;
  } elsif ( $cmd eq 'open' ) {
    my $query = "$fwcfg --q Outgoing";
    my $state = `$query`;
    &debug(3,"$query retured $state");
    if ( $state =~ /not blocked/ ) { 
      &debug(2,"Outbound connections NOT blocked, no need to tweak firewall rules");
      return undef;
    }
    &prompt_luser("open outbound tcp connections to fetch patch info and patches.") unless ($tweak_ok);
    $query = "$fwcfg --allowOutgoing";
    &debug(1,"Allowing outbound connections through firewall, will block when done");
    &debug(3,"Calling $query to open firewall");
    &my_sys("$query >>$logfile 2>&1");
    return 1;
  }

}

sub debug {

# Print/Log what's happening

  my ($val,$msg) = @_;
  my $time = localtime;
  print LOG "$time - $msg\n";
  return if ( $val > $debug );
  print STDOUT "$msg\n";

}

sub handler {

# KISS for now

  &debug(0,$alarm_msg) if ($alarm_msg);
  &end_it_now("Alarm timer expired, something went poorly or hung.");

}

sub reset_alarm {

  alarm(0);
  undef $alarm_msg;

}

sub got_root {

# Got R00T?
    die "Sorry, you must be root and ^this tall^ to patch ESX.  Exiting...\n" if ( $> );

}

sub Usage {

  my $usage = qq[esx_patcher - v$revision

esx_patcher [-y] [-tmr] [-d debug] [-h hostname] [-p proxy] [-l path ] [-t timeout] [-s path]

-y 		=> put in maint mode, tweak firewall rules, reboot as needed without prompt
-m		=> put in/out of maint mode without prompting
-t 		=> tweak firewall rules without prompting
-r		=> reboot system without prompting
-d #		=> debug level: 0=terse, 1=normal(default), 2=chatty, 3=insane
-h hostname  	=> hostname to look for patch depot on (e.g. linuxcoe.corp.hp.com)
-l path		=> path on hostname to look for patch depot (e.g. /LinuxCOE/VMWare-updates/ESX)
-p proxy	=> use a proxy for network requests (e.g. http://192.168.1.1:8088)
-t timeout 	=> Seconds before declaring a process hung and aborting (default 600 - 10 minutes)
-s path		=> Scratch directory to use (default /var/updates)

Results are logged in $logfile.

];

  return($usage);

}

sub my_sys {

# Wrap system to check return value 

  my $sys = shift;
  &debug(2,"Executing: $sys");
  my $ret = system $sys;
  if ( $ret ) {
    my $ec = $ret/256;
    &debug(0,"$sys returned error code $ec, exiting!");
    &end_it_now; 
  }

}

sub end_it_now {

# Cleanup what we did and exit;

  my $msg = shift;
  &tweak_firewall('block') if $opened;                    # If we opened it, close it
  &maint_mode('exit') if $mm_ent;                         # If we entered it, exit it
  &debug(0,$msg) if $msg;
  &debug(0,"See $logfile for details");
  close(LOG);
  exit;

}

sub process_options {

# Override defualt if needed 

  my %o;
  getopts('h?ymtrd:h:l:p:t:s:', \%o);  		# Slurp in args
  die(&Usage) if (($o{h}) || ( $o{'?'}));
  $maint_ok = 1 if (($o{y}) || ($o{m}));
  $tweak_ok = 1 if (($o{y}) || ($o{t}));
  $reboot_ok = 1 if (($o{y}) || ($o{r}));
  $debug = $o{d} || $debug;
  $waystation = $o{h} || $waystation;
  $local_store = $o{s} || $local_store;
  $proxy = $o{p} || $proxy;
  $path = $o{l} || $path; 
  $alarm_time = $o{t} || $alarm_time;

}
