#! /usr/bin/perl -w
# $Header: /global/src/etc/cl-manage-scripts/RCS/cl-patch-file,v 1.23 2018/11/22 02:50:40 pb22 Exp $
# Use "pod2text" for info

=head1 NAME

patch-file - Conditionally patch a file

=head1 SYNOPSIS

patch-file I<$patchfile>*

=head1 OPTIONS

=over 4

=item B<--auto|a>

do an auto patch, i.e. those listed in /etc/user-config/patches

=item B<--verbose|v+>

generate comments as to what it is doing.

=item B<--noexec|n>

print what it would do, bit do not exec the commands.

=item B<--exec|e>

exec the commands.

=item B<--help|h>

generate help info.

=back

=head1 DESCRIPTION

The arguments supplied (possibly implied by B<-a>) are a set of
 I<patchfile>: a file describing what a possible patch
 B<@>I<pathfile>: a file to read for more arguments
 B<NOT_>I<patchfile>: do B<NOT> apply named patch
 B<SET> I<key> B<=> I<val>: set I<key> to <val>

Patch files are read from the directory /etc/{cl-,}manage-scripts/patches/

The values which can be set are:
 B<exec>: set to B<yes> or B<on> to apply patches, B<no> of B<off> to disable

Each patch file will describe how to conditionally patch a file, based on
 1) whether it needs it
 2) whether it should be sys managed or user managed

Read the patch file to discover
 1) which file it is editing 
 2) the condition under which the patch needs to be applied
 3) whether it is mandatory (users can arrange to meet the conditions)
 4) how to perform the edit

Try to make it simple to generate the patch file,
e.g. get the filename from std patch format files.

It looks for the keys:
 B<filename> (file to edit),
 B<distmatches> (the distribution must match the regexp to consider the patch)
 B<matches> (no patch needed if the file matches),
 B<hasno> (no patch needed if the file has no match),
 B<fileexists> (no patch needed if the file exists),
 B<filemissing> (no patch needed if the file does not exist),
 B<chkconfig> (no action needed if suitable init.d),
 B<operation> (what operation to use to make the patch),
 B<multipatches> (there are more than one atomic patches, so compare cksum)
 prefixed by 'B<#\s*patch-file\s+>'.


The B<--verbose> (or B<-v>) flag can be repeated to set the level of logging, e.g.
 B<1> shows commands it is about to run.
 B<2> shows commands that will be run if the "I<match>" is incorrect.
 B<3> shows commands that might match on this distribution.
 B<5> shows lots of debugging info
=cut

$basedir = "/etc/cl-manage-scripts/patches/";
$basedir = "/etc/manage-scripts/patches/" unless -e $basedir;

# Possible operations to perform
$PATCH		= "patch";
$SHELL		= "shell";

$MATCHES	= "matches";
$HASNO		= "hasno";
$DISTMATCH	= "distmatches";
$CHKCONFIG	= "chkconfig";
$FILEEXISTS	= "fileexists";
$FILEMISSING	= "filemissing";
$MULTIPATCHES	= "multipatches";
$AUTOFILE	= "@/etc/user-config/patches";

$NOT		= "NOT_";
$SET		= "SET";

$pragma		= '#\s*patch-file\s+';

$verbose	 = 0;
$sum_was = $sum_now = "-";

($cname = $0) =~ s=^/usr/bin/==;
$cname =~ s=/global/src/etc/.*manage-scripts/=<gsem>/=;

use Getopt::Long;
use Pod::Usage;
%opts = (
	"auto|s"	=> \$auto,
	"verbose|v+"	=> \$verbose,
	"noexec|n"	=> \$noexec,
	"exec|e"	=> \$exec,
	"help|h"	=> \$help,
);

$num_args = $#ARGV;

GetOptions (%opts) || pod2usage();
pod2usage(-verbose => 2) if $help;
# Sanity check on args.
die("$0: both --exec and --noexec set!\n") if $exec && $noexec;
# No args at all => "-a -n"
$noexec = $auto = 1 if $num_args < 0;
# Load up defaults if --auto.
push @ARGV, $AUTOFILE if $auto;

$ENV{'CALLER'} = $0;
$ENV{'PATH'} = "/bin:/usr/bin:/sbin:/usr/sbin:$ENV{'PATH'}";
$exit = 0;
$suff = "-preP";

# If using SELinux, find a command to restore the file once patched
$restorecon = "/etc/rc.scripts/restorecon";
if (-x $restorecon) { ; }
elsif (-x ($restorecon = "${basedir}restorecon")) { ; }
elsif (-x ($restorecon = "/sbin/restorecon")) { ; }
else  { $restorecon = ""; }

foreach $patchfile (@ARGV) {
	# Look for recusrion
	if ($patchfile =~ /^\@(\S+)/) {
		my($file) = $1;
		unless (open(FILE, $file) || open(FILE, "$basedir$file")) {
			# $file = "file" if $SUDOUSER && $auto;
			print STDERR "$0: failed to find $file\n";
			next;
		}
		my(@lines) = <FILE>;
		close(FILE);
		foreach $line (@lines) {
			chomp($line);
			next if $line =~ /^$/ || $line =~ /^#/;
			push @ARGV, $line;
		}
		next;
	}

	# Set a requested value
	if ($patchfile =~ /^$SET\s*(\S+)\s*=\s*(\S+)\s*$/o) {
		print STDERR "# $cname: set to $1 to $2\n" if $verbose > 4;
		$doexec = $2 if $1 eq "exec";
		next;
	}
	# Deal with negations: look for "NOT_" prefix, and deal with pre "NOT_"ed files
	if ($patchfile =~ s/^$NOT//o) {
		$ignore{$patchfile} = 1;
		print STDERR "# $cname: set to ignore $patchfile\n" if $verbose > 4;
		next;
	}
	if ($ignore{$patchfile}) {
		print STDERR "# $cname: ignore $patchfile\n" if $verbose > 4;
		next;
	}

	# Find the patch file
	unless (-r $patchfile) {
		if ($patchfile =~ /^$basedir/o) {
			print STDERR "$0: Failed to find $patchfile: $!\n";
			$exit++;
			next;
		}
		if (-r "$basedir$patchfile") {
			$patchfile = "$basedir$patchfile";
		} else {
			@files = <$basedir$patchfile>;
			if ($#files == 0 && $files[0] eq "$basedir$patchfile") {
				print STDERR "$0: Failed to find $patchfile (@files): $!\n";
				$exit++;
				next;
			}
			print STDERR "# $cname: $basedir$patchfile -> $#files: @files\n" if $verbose > 4;
			push @ARGV, @files;
			next;
		}
	}

	# Read the patch file to decide whether it needs to be invoked
	($pfile = $patchfile) =~ s=^$basedir==;
	open(FILE, $patchfile) || die("$0: Failed to open $patchfile: $!\n");
	$contents = "\n";
	while (<FILE>) { $contents .= $_; }
	$contents .= "\n";
	close(FILE) || die("$0: Failed to close $patchfile: $!\n");
	$distmatches = $chkconfig = $fileexists = $filemissing = $file = $condition = "";
	$multipatches = 0;
	$do_restore = 1;
	$matcht = -1; # Don't open the file
	$operation = $PATCH;

	# Locate the file to process: explicit pragma over rides patch file "---"
	$file = $1 if $contents =~ /\n--- (\S+)\s+20\d\d/;
	$file = $1 if $contents =~ /\n${pragma}filename\s*[=:]\s*(\S+)\s*\n/i;

	# Set the conditions to apply patch based on pragmas
	$condition = $1, $matcht = 1 if $contents =~ /\n$pragma$MATCHES\s*[=:]\s*(\S+)\s*\n/i;
	$distmatches = $1, if $contents =~ /\n$pragma$DISTMATCH\s*[=:]\s*(\S+)\s*\n/i;
	$condition = $1, $matcht = 0 if $contents =~ /\n$pragma$HASNO\s*[=:]\s*(\S+)\s*\n/i;
	$fileexists = $1 if $contents =~ /\n$pragma$FILEEXISTS\s*[=:]\s*(\S+)\s*\n/i;
	$filemissing = $1 if $contents =~ /\n$pragma$FILEMISSING\s*[=:]\s*(\S+)\s*\n/i;
	$chkconfig = $1, $operation = $CHKCONFIG if $contents =~ /\n$pragma$CHKCONFIG\s*[=:]\s*(\S+)\s*\n/i;
	$operation = $1 if $contents =~ /\n${pragma}operation\s*[=:]\s*(\S+)\s*\n/i;
	$multipatches = 1 if $contents =~ /\n$pragma$MULTIPATCHES\s*\n/i;
	$suff = $1 if $contents =~ /\n${pragma}SUFFIX\s*[=:]\s*(\S+)\s*\n/i; # Don't think we want ALL CAPITALS
	$suff = $1 if $contents =~ /\n${pragma}suffix\s*[=:]\s*(\S+)\s*\n/i;

	$File = $file || $fileexists || $filemissing;

	$ENV{'cl_patch_file_File'} = $File;
	$ENV{'cl_patch_file_suff'} = $suff;
	$ENV{'cl_patch_file_matches'} = $condition;
	# Check that some condition was set
	print STDERR  "# $cname: $operation '$pfile' on '$File'\n" if $verbose >= 3;
	if ($operation eq $PATCH && $condition eq "" && $file ne "" && $file ne $fileexists) {
		die("$0: $patchfile contains no condition, so would unconditionally patch $file $filemissing\n");
	}

	print "# $cname: patchfile=$patchfile file=$file operation=$operation condition=$condition, matcht=$matcht, fileexists=$fileexists, filemissing=$filemissing, chkconfig=$chkconfig distmatches=$distmatches multipatches=$multipatches\n" if $verbose > 4;

	# Skip unless the distribution matches
	next if $distmatches && ! &distmatches($distmatches);

	print STDERR  "# $cname: $operation '$pfile' on '$File'\n" if $verbose >= 2;
	# If trying to do a match based on the contents of a file ...
	if ($matcht >= 0) {
	    # Open the file to decide whether to process it
	    die("$0: no filename found\n") if $file eq "";
	    unless (-r $file || $fileexists) {
		print STDERR "# $cname: $pfile: $file does not exist\n" if $verbose > 4;
		next;
	    }
	    unless (open(FILE, $file)) {
		print STDERR "$0: Failed to open $file: $!\n"; $exit++; next; }
	    $data = "";
	    while (<FILE>) { $data .= $_; }
	    close(FILE) || die("$0: Failed to close $file: $!\n");
	    if ($matcht == 1 && $data =~ /$condition/m) {
		print STDERR  "# $cname: $pfile: no need to process $file as matches '$condition'\n" if $verbose > 4;
		next;
	    }
	    if ($matcht == 0 && $data !~ /$condition/m) {
		print STDERR  "# $cname: $pfile: no need to process $file as has no '$condition'\n" if $verbose > 4;
		next;
	    }
	}

	# Trying to do a match based on the file existing?
	if ($fileexists && -r $fileexists) {
		print STDERR "# $cname: $pfile: no need to process $file as $fileexists exists\n" if $verbose > 4;
		next;
	}

	# Trying to do a match based on the file missing?
	if ($filemissing && ! -r $filemissing) {
		print STDERR "# $cname: $pfile: no need to process $file as $filemissing does not exist\n" if $verbose > 4;
		next;
	}

	# If match is based on SYSV iniot settings, check what is set
	if ($chkconfig) {
		$res = `chkconfig $file --list 2>&1`;
		if ($res =~ /error reading information (on|about) service .*: No such file or directory/) {
			print STDERR "# $cname: $pfile: no need to process $file as not available:\n# $res" if $verbose > 4;
			next;
		}
		if ($res =~ /$chkconfig/) {
			print STDERR "# $cname: $pfile: no need to process $file as $chkconfig matches:\n# $res" if $verbose > 4;
			next;
		}
		$do_restore = 0;
	}

	# All tests done, so log decicion if verbose
	print STDERR  "# $cname: $pfile process $file as does not match '$condition' '$fileexists' '$filemissing' '$chkconfig'\n" if $verbose >= 1;

	print STDERR  "# $cname: $operation '$pfile' on '$File'\n" if $verbose >= 1;
	# Decide what to do
	if ($operation eq $PATCH) {
		# Decide what args to pass to patch
		($pargs, $suf) = (-r "$file$suff" || $file =~ m=/ifcfg-= || $file =~ m=^/etc/grub.d/=)
			? ("", ".orig")
			: ("-b --suffix=$suff", "$suff");
		$cmd = "patch $pargs --forward < $patchfile $file";
	}
	elsif ($operation eq $SHELL) {
		$cmd = "sh $patchfile $file";
	}
	elsif ($operation eq $CHKCONFIG) {
		$op = ($chkconfig =~ /on/) ? "on" : "off";
		$cmd = "chkconfig $file $op";
	}
	else { die("$0: unrecognised operation '$operation'\n"); }

	# Do it!
	unless (&shouldexec) {
		print "$cmd\n";
		print("rm -f $file.rej\n") if $multipatches;
		print "$restorecon -v $file\n" if $restorecon && $file && $do_restore;
		$exit++;
		next;
	}
	chomp($sum_was = `sum $file`) if $multipatches;
	$rc = system($cmd);
	chomp($sum_now = `sum $file`) if $multipatches;
	print STDERR  "# $cname: '$cmd' gave $rc ($sum_was:$sum_now)\n" if $verbose > 4;
	$rc = 0 if $multipatches && $sum_was ne $sum_now;

	# Tidy up depending on how the patch went
	$prefix = "Patched $file with $pfile ($operation)";
	if ($rc == 0) {
		system("rm -f $file.rej") if $multipatches;
		print "$prefix\n" if $file;
		system("$restorecon -v $file") if $restorecon && $file && $do_restore;
	} elsif ($operation ne $PATCH) {
		print "$prefix failed $rc\n";
		$exit++;
	} elsif ($rc == 1 || $rc == 256) {
		print "$prefix failed $rc - backing off changes\n";
		system("rm -f $file.rej; mv -f $file$suf $file") if $file;
		$exit++;
	} elsif ($rc == 2 || $rc == 512) {
		print "$prefix FAILED $rc - backing off any changes\n";
		system("rm -f $file.rej; mv -f $file$suf $file") if $file;
		$exit++;
	} else {
		print "$prefix gave $rc\n";
		$exit++;
	}
}

exit($exit);

# ---------------------------------------------------------------------------

# Should the command be executed or not?
sub shouldexec {
	# Use command line flag if set
	return 1 if $exec;
	return 0 if $noexec;

	# Use default read from patches file
	return 1 if defined($doexec) && $doexec =~ /^(yes|y|on)$/i;
	return 0 if defined($doexec) && $doexec =~ /^(no|n|off)$/i;

	# Use distribution default
	return 1 if &distmatches("Fedora|CentOS");
	return 1 if &distmatches("Debian|Ubuntu");
	return 1 if &distmatches("Citrix XenServer|Citrix  Host [6-9]");

	# Unknown distro -- fail safe
	return 0;
}

# Does the distribution match?
sub distmatches {
	my($distmatches) = @_;
	($diststring = `cat /etc/issue`) =~ s/\n/ /mg unless defined $diststring;
	unless ($diststring =~ /$distmatches/) {
		print "# $cname: $pfile: skip as <$diststring> does not match <$distmatches>\n" if $verbose > 4;
		return 0;
	}
	# print "# $0: $patchfile: consider as <$diststring> matches <$distmatches>\n" if $verbose;
	return 1;
}
