#! /usr/bin/env python

# Copyright (c) 1997 Barry A. Warsaw

# Version 1.0

"""Download a Solaris patch set.

The set of patches to retrieve is extracted from a *.PatchReport file,
which has a very regular format.  By default, the most current
PatchReport file will be retrieved from Sunsolve, and will be checked
for missing patches.  Any missing patches will be downloaded,
uncompressed, and un-tar'd.

Usage:

    %(program)s [-c] [-h] [ -n ] systype

    --nodownload
    -n           -- Do not download the summary files from Sunsolve.
                    Instead use `PatchSummary' and `Solaris*.PatchReport'
		    in the current directory.

    --checkonly
    -c           -- Check only.  Do not download missing files (note that
                    if --file is not used, the patch report file will
                    still be downloaded)

    --help
    -h           -- print this message

    systype      -- the system type you want patches for.  This is
                    required and the list of valid system types is
                    given below.
"""

PATCHSITE = 'sunsolve1.sun.com'
PATCHLOGIN = 'YOUR.LOGIN.HERE'
PATCHPASSWD = 'YOUR.PASSWORD.HERE'
PATCHACCOUNT = 'YOUR.ACCOUNT.HERE'

# system types
SYSTYPES = [
    # index is key, value is leader for *.PatchReport file
    'Solaris2.5',			# Sparc
    'Solaris2.5_x86',
    'Solaris2.5.1',			# Sparc
    'Solaris2.5.1_x86',
    'Solaris2.5.1_ppc',
    ]

CDE_REGEXPS = [
    # parallel list to SYSTYPES
    '1.0.1',				# Sparc
    '1.0.1_x86',			# ' Emacs font-lock turd
    '1.0.2',				# Sparc
    '1.0.2_x86',
    '1.0.2_ppc',
    ]

# CDE rev numbers corresponding to OS releases
CDE = [
    # parallel list to SYSTYPES
    '1.0.1',
    '1.0.1',
    '1.0.2',
    '1.0.2',
    '1.0.2',
    ]

import os
import regex
import sys
import getopt
import string
import time
import thread
import ftplib

patch_re = '\(\(' + ('[0-9]' * 6) + '\)-\(' + ('[0-9]' * 2) + '\)\)'

cre_unpacked = regex.compile(patch_re)
cre_packed = regex.compile(patch_re + '.tar.Z')

program = sys.argv[0]


def usage(status):
    print __doc__ % globals()
    for i, t in map(None, range(len(SYSTYPES)), SYSTYPES):
	print '    ', i, ':', t
    print
    sys.exit(status)


def tarball_cmp(a, b):
    # filenames of the form PATCHNUM-REV.tar.Z
    # assumes PATCHNUMs are the same, sorts on REVs
    rev_a = -1
    rev_b = -1
    if cre_tarball.match(a) >= 0:
	rev_a = string.atoi(cre_packed.group(1))
    if cre_tarball.match(b) >= 0:
	rev_b = string.atoi(cre_packed.group(1))
    return cmp(rev_a, rev_b)


class SunsolveFTP(ftplib.FTP):
    def __init__(self, systype):
	ftplib.FTP.__init__(self)
	self.__reportfile = SYSTYPES[systype] + '.PatchReport'
	self.__systype = systype
	self.__fp = None
	self.__logged_in = None

    def __del__(self):
	if self.__logged_in:
	    self.quit()

    def __login(self):
	if not self.__logged_in:
	    print 'Connecting...'
	    self.connect(PATCHSITE)
	    self.login(PATCHLOGIN, PATCHPASSWD, PATCHACCOUNT)
	    self.__logged_in = 1

    def __save(self, data):
	self.__fp.write(data)

    def retrieve(self, filename, subdir='.'):
	target = os.path.join(subdir, filename)
	print 'retrieving:', filename,
	if subdir <> '.':
	    print 'to target directory:', subdir
	else:
	    print
	try:
	    self.__fp = open(target, 'wb')
	except IOError, (errno, msg):
	    print '%s: %s' % (msg, target)
	    return None
	try:
	    try:
		self.retrbinary('RETR ' + filename, self.__save, 8096)
		return filename
	    except (ftplib.error_perm, ftplib.error_temp), msg:
		print 'FTP ERROR:', msg
		os.unlink(target)
		return None
	finally:
	    self.__fp.close()
	    self.__fp = None

    def get_patch_report(self):
	self.__login()
	reportfile = self.__reportfile
	files = self.nlst(self.__reportfile)
	if len(files) <> 1 or \
	   files[0] <> reportfile or \
	   not self.retrieve(reportfile):
	    #
	    print 'Problem getting', reportfile
	    reportfile = None
	# also always grab the PatchSummary file
	if not self.retrieve('PatchSummary'):
	    print 'Problem getting PatchSummary'
	return reportfile, 'PatchSummary'

    def find_latest_rev(self, patchnum):
	self.__login()
	wildcard = patchnum + '-*.tar.Z'
	try:
	    files = self.nlst(wildcard)
	except (ftplib.error_perm, ftplib.error_temp), msg:
	    print 'FTP ERROR:', msg, '(must be obsolete)'
	    return None
	if len(files) == 0:
	    print 'No revisions found for patchid:', patchid
	    return None
	if len(files) > 1:
	    files.sort(tarball_cmp)
	return files[-1]


class Unpacker:
    def __init__(self):
	self.__lock1 = thread.allocate_lock()
	self.__lock2 = thread.allocate_lock()
	self.__packedfiles = []
	self.__unpacking = []
	thread.start_new_thread(self.__do_unpacking, ())

    def unpack(self, tarball, subdir='.'):
	self.__lock1.acquire()
	self.__packedfiles.append((tarball, subdir))
	self.__lock1.release()

    def still_unpacking(self):
	self.__lock1.acquire()
	packedcnt = len(self.__packedfiles)
	self.__lock1.release()
	self.__lock2.acquire()
	unpackingcnt = len(self.__unpacking)
	self.__lock2.release()
	return packedcnt + unpackingcnt > 0

    def __do_unpacking(self):
	while 1:
	    file = None
	    self.__lock1.acquire()
	    self.__lock2.acquire()
	    if len(self.__packedfiles) > 0:
		file, subdir = self.__packedfiles[0]
		del self.__packedfiles[0]
		self.__unpacking.append(file)
	    self.__lock2.release()
	    self.__lock1.release()
	    if file:
		thread.start_new_thread(self.__unpack_file, (file, subdir))

    def __unpack_file(self, file, subdir):
	try:
	    print 'unpacking:', file
	    cmd = '(\ncd ' +subdir+ '; zcat -c ' +file+ ' | tar xf -\n) 2>&1'
	    pipe = os.popen(cmd, 'r')
	    pipe.read()
	    status = pipe.close()
	    if not status:
		rmfile = os.path.join(subdir, file)
		try:
		    os.unlink(rmfile)
		except os.error, v:
		    print 'Could not unlink:', rmfile
	finally:
	    self.__lock2.acquire()
	    self.__unpacking.remove(file)
	    self.__lock2.release()


class PatchMatcher:
    def __init__(self, file, cre, patch_basedir=''):
	self.__basedir = patch_basedir
	#
	# public ivars
	#
	self.patches = []
	self.published = []
	self.exists_packed = []
	self.exists = []
	self.newer = []
	self.need = []
	#
	# grep patch Ids out of report file.  we want only the 6 digit
	# patch number, sans the revision number, which may not be
	# accurate.
	#
	try:
	    fp = open(file, 'r')
	except IOError, (errno, msg):
	    print '%s: %s' % (msg, file)
	while 1:
	    line = fp.readline()
	    if line == '':
		break
	    if cre.match(line) >= 0:
		self.patches.append(cre.group(2))
		self.published.append(cre.group(1))
	fp.close()
	
    def verify(self, ftp):
	for patchnum, patchid in map(None, self.patches, self.published):
	    tarball = ftp.find_latest_rev(patchnum)
	    if not tarball:
		continue
	    tb_patchid = string.split(tarball, '.')[0]
	    # case 1: the tarball exists.  assume it has not been unpacked
	    if os.path.exists(os.path.join(self.__basedir, tarball)):
		self.exists_packed.append(tarball)
		continue
	    # case 2: the directory exists.  it's been unpacked already
	    elif os.path.exists(os.path.join(self.__basedir, tb_patchid)):
		self.exists.append(tb_patchid)
		continue
	    # case 3: it doesn't exist, but was a newer one found than
	    # the published patch id?
	    if tb_patchid <> patchid:
		self.newer.append((patchid, tb_patchid))
	    # we need it, and the target directory is the current dir
	    self.need.append(tarball)

    def summarize(self):
	print '    ', len(self.exists), 'found (list suppressed)'

	print '    ', len(self.exists_packed), 'found, but not unpacked'
	for p in self.exists_packed:
	    print '        ', p

	print '    ', len(self.newer), 'missing (found newer than published)'
	for published, found in self.newer:
	    print '        published:', published, 'found:', found

	print '    ', len(self.need), 'missing'
	for tarball in self.need:
	    print '        ', string.split(tarball, '.')[0]


class SolPatchMatcher(PatchMatcher):
    def __init__(self, reportfile):
	PatchMatcher.__init__(self, reportfile,
			      regex.compile('Patch-ID# ' + patch_re))


class CDEPatchMatcher(PatchMatcher):
    def __init__(self, summaryfile, systype):
	#
	# public ivars
	#
	patches = []
	self.basedir = 'CDE_' + CDE[systype]
	#
	# grep patch Ids out of summary file.  we want only the 6
	# digit patch number, sans the revision number, which may not
	# be accurate.
	#
	cre = regex.compile(patch_re + '[ \t]*CDE ' + CDE_REGEXPS[systype])
	PatchMatcher.__init__(self, summaryfile, cre, self.basedir)


def main():
    # scan argument list
    error = 0
    help = 0
    retrieve = 0
    reportfile = None
    summaryfile = 'PatchSummary'
    checkonly = None

    # very important
    os.umask(002)

    try:
	opts, args = getopt.getopt(
	    sys.argv[1:], 'hnc',
	    ['help', 'nodownload', 'checkonly'])
    except getopt.error:
	usage(1)

    if len(args) <> 1:
	usage(1)

    try:
	systype = string.atoi(args[0])
	print 'Downloading patches for system type:', SYSTYPES[systype]
    except (ValueError, KeyError):
	print 'bad systype:', args[0]
	usage(1)

    for opt, arg in opts:
	if opt in ('-h', '--help'):
	    help = 1
	elif opt in ('-n', '--nodownload'):
	    for reportfile in os.listdir('.'):
		if reportfile[-12:] == '.PatchReport':
		    break
	    else:
		print 'Could not find a PatchReport file'
		sys.exit(1)
	elif opt in ('-c', '--checkonly'):
	    checkonly = 1

    if help:
	usage(error)

    ftp = SunsolveFTP(systype)

    if reportfile:
	print 'Using patch report files:', reportfile, summaryfile
    else:
	# retrieve patch report file
	print 'Retrieving latest patch report'
	reportfile, summaryfile = ftp.get_patch_report()
	if not reportfile:
	    print 'Could not file a Patch Report file!  Exiting.'
	    sys.exit(1)

    # verify all files
    sol = SolPatchMatcher(reportfile)
    sol.verify(ftp)
    print 'Summary for', SYSTYPES[systype]
    sol.summarize()

    cde = CDEPatchMatcher(summaryfile, systype)
    cde.verify(ftp)
    print 'Summary for CDE', CDE[systype], 'for this architecture'
    cde.summarize()

    if checkonly:
	return

    # TBD: really should handle KeyboardInterrupt while these
    # sub-threads are running
    unpacker = Unpacker()

    for tarball in sol.need:
	status = ftp.retrieve(tarball)
	if status:
	    unpacker.unpack(tarball)

    for tarball in cde.need:
	status = ftp.retrieve(tarball, cde.basedir)
	if status:
	    unpacker.unpack(tarball, cde.basedir)

    # idle the main thread until all files are unpacked
    while unpacker.still_unpacking():
	time.sleep(3)


if __name__ == '__main__':
    main()
