#!/usr/bin/python -tt
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License
# as published by the Free Software Foundation.
#
# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.

# (c)2006-2007 Duke University, Red Hat, Inc.
# Seth Vidal <skvidal@linux.duke.edu>
# Jeremy Katz <katzj@redhat.com>

import sys
import gzip
import dbus
import dbus.service
import dbus.glib
import smtplib
from optparse import OptionParser
from email.MIMEText import MIMEText
import socket
import rfc822
import subprocess


import yum
import yum.Errors
import syslog
from yum.constants import *
from yum.update_md import UpdateMetadata

# FIXME: is it really sane to use this from here?
sys.path.append('/usr/share/yum-cli')
import callback

from yum.constants import YUM_PID_FILE

class UpdateEmitter(object):
    """Abstract object for implementing different types of emitters."""
    def __init__(self):
        pass
    def updatesAvailable(self, updateInfo):
        """Emitted when there are updates available to be installed.
        If not doing the download here, then called immediately on finding
        new updates.  If we do the download here, then called after the
        updates have been downloaded."""
        pass
    def updatesDownloading(self, updateInfo):
        """Emitted to give feedback of update download starting."""
        pass
    def updatesInstalling(self, updateInfo):
        """Emitted to give feedback of update download starting."""
        pass
    def updatesApplied(self, updateInfo):
        """Emitted on successful installation of updates."""
        pass
    def updatesFailed(self, errmsgs):
        """Emitted when an update has failed to install."""
        pass
    def checkFailed(self, error):
        """Emitted when checking for updates failed."""
        pass
    def setupFailed(self, error, translation_domain):
       """Emitted when plugin initialization failed."""
       pass
    def locked(self, error):
        """Emitted when the yum lock is already held."""
        pass
    def updateInfo(self, update):
        """Emitted with information on available updates (async)"""
        pass
   

class SyslogUpdateEmitter(UpdateEmitter):
    def __init__(self, syslog_facility, ident = "yum-updatesd",
                 level = "WARN"):
        UpdateEmitter.__init__(self)
        syslog.openlog(ident, 0, self._facilityMap(syslog_facility))
        self.level = level
        
    def updatesAvailable(self, updateInfo):
        num = len(updateInfo)
        level = self.level
        if num > 1:
            msg = "%d updates available" %(num,)
        elif num == 1:
            msg = "1 update available"
        else:
            msg = "No updates available"
            level = syslog.LOG_DEBUG

        syslog.syslog(self._levelMap(level), msg)

    def _levelMap(self, lvl):
        level_map = { "EMERG": syslog.LOG_EMERG,
                      "ALERT": syslog.LOG_ALERT,
                      "CRIT": syslog.LOG_CRIT,
                      "ERR": syslog.LOG_ERR,
                      "WARN": syslog.LOG_WARNING,
                      "NOTICE": syslog.LOG_NOTICE,
                      "INFO": syslog.LOG_INFO,
                      "DEBUG": syslog.LOG_DEBUG }
        if type(lvl) == int:
            return lvl
        if level_map.has_key(lvl.upper()):
            return level_map[lvl.upper()]
        return syslog.LOG_INFO

    def _facilityMap(self, facility):
        facility_map = { "KERN": syslog.LOG_KERN,
                         "USER": syslog.LOG_USER,
                         "MAIL": syslog.LOG_MAIL,
                         "DAEMON": syslog.LOG_DAEMON,
                         "AUTH": syslog.LOG_AUTH,
                         "LPR": syslog.LOG_LPR,
                         "NEWS": syslog.LOG_NEWS,
                         "UUCP": syslog.LOG_UUCP,
                         "CRON": syslog.LOG_CRON,
                         "LOCAL0": syslog.LOG_LOCAL0,
                         "LOCAL1": syslog.LOG_LOCAL1,
                         "LOCAL2": syslog.LOG_LOCAL2,
                         "LOCAL3": syslog.LOG_LOCAL3,
                         "LOCAL4": syslog.LOG_LOCAL4,
                         "LOCAL5": syslog.LOG_LOCAL5,
                         "LOCAL6": syslog.LOG_LOCAL6,
                         "LOCAL7": syslog.LOG_LOCAL7,}
        if type(facility) == int:
            return facility
        elif facility_map.has_key(facility.upper()):
            return facility_map[facility.upper()]
        return syslog.LOG_DAEMON


class EmailUpdateEmitter(UpdateEmitter):
    def __init__(self, sender, rcpts, smtp_server, sendmail):
        UpdateEmitter.__init__(self)        
        self.sender = sender
        self.rcpts = rcpts
        self.smtp_server = smtp_server
        self.sendmail = sendmail

    def _msgGreeting(self):
        output = """Hi,
This is the automatic update system on %s.

""" % socket.gethostname()
        return output

    def _msgFooter(self):
        output = """
Thank You,
Your Computer
"""
        return output

    def _msgPacketList(self, updateInfo):
        output = ""
        for package in updateInfo:
            if package[0].has_key('type'):
                output += "    %-30s %-10s\n" % (package[0]['name'], package[0]['type'])
            else:
                output += "    %-30s\n" % (package[0]['name'])
        return output

    def _sendMessage(self, subject, body):
        msg = MIMEText(body)
        msg['Subject'] = "yum: %s (on %s) " % (subject, socket.gethostname())
        msg['From'] = self.sender
        msg['To'] = self.rcpts
        msg['Date'] = rfc822.formatdate()

        if self.sendmail:
            p = subprocess.Popen(("/usr/sbin/sendmail", "-t"), stdin=subprocess.PIPE)
            p.stdin.write(str(msg))
            p.communicate()
        else:
            s = smtplib.SMTP()
            if self.smtp_server:
                s.connect(self.smtp_server)
            else:
                s.connect()
            s.sendmail(self.sender, self.rcpts.split(','), msg.as_string())
            s.close()

    def updatesAvailable(self, updateInfo):
        num = len(updateInfo)
        if num < 1:
            return

        output = self._msgGreeting()

        output += """There are %d package updates available. Please run the system updater.

Packages available for update:

""" % num

        output += self._msgPacketList(updateInfo)
        output += self._msgFooter()

        self._sendMessage("%d Updates Available" % num, output)

    def updatesApplied(self, updateInfo):
        num = len(updateInfo)
        if num < 1:
            return

        output = self._msgGreeting()

        output += """The system successfully installed/updated %d packages.

Packages installed or updated:

""" % num

        output += self._msgPacketList(updateInfo)
        output += self._msgFooter()

        self._sendMessage("%d packets installed/updated" % num, output)

    def updatesFailed(self, errmsgs):
        output = self._msgGreeting()

        output += """There was a problem updating the system. The following error message
was reported:

%s

If the problem persists, manual intervention may be required.""" % errmsgs

        output += self._msgFooter()

        self._sendMessage("problem updating system", output)

class DbusUpdateEmitter(UpdateEmitter):
    def __init__(self):
        UpdateEmitter.__init__(self)        
        bus = dbus.SystemBus()
        name = dbus.service.BusName('edu.duke.linux.yum', bus = bus)
        yum_dbus = YumDbusInterface(name)
        self.dbusintf = yum_dbus

    def updatesAvailable(self, updateInfo):
        num = len(updateInfo)
        msg = "%d" %(num,)
        if num > 0:
            self.dbusintf.UpdatesAvailableSignal(msg)
        else:
            self.dbusintf.NoUpdatesAvailableSignal(msg)

    def updatesFailed(self, errmsgs):
        self.dbusintf.UpdatesFailedSignal(errmsgs)

    def updatesApplied(self, updinfo):
        self.dbusintf.UpdatesAppliedSignal(updinfo)

    def checkFailed(self, error):
        self.dbusintf.CheckFailedSignal(error)

    def setupFailed(self, error, translation_domain):
        self.dbusintf.SetupFailedSignal(error, translation_domain)

    def updateInfo(self, upd):
        self.dbusintf.UpdateInfoSignal(upd)

    def locked(self, error):
        self.dbusintf.LockedSignal(error)

class StderrUpdateEmitter(UpdateEmitter):
    def __init__(self):
        UpdateEmitter.__init__(self)        

    def updatesAvailable(self, updateInfo):
        print >> sys.stderr, "%d updates available" %(len(updateInfo),)

    def updatesFailed(self, errmsgs):
        print >> sys.stderr, "Updates failed: %s" %(errmsgs,)

    def updatesApplied(self, updinfo):
        print >> sys.stderr, "Updates applied successfully: %s" %(updinfo)

    def checkFailed(self, error):
        print >> sys.stderr, "Check for updates failed: %s" %(error,)

    def setupFailed(self, error, translation_domain):
        print >> sys.stderr, "Update setup failed: %s" %(error,)

    def updateInfo(self, upd):
        print >> sys.stderr, "Update available: %s" %(upd,)

    def locked(self, error):
        print >> sys.stderr, "Yum lock already held: %s" %(error,)

class YumDbusInterface(dbus.service.Object):
    def __init__(self, bus_name, object_path='/UpdatesAvail'):
        dbus.service.Object.__init__(self, bus_name, object_path)

    @dbus.service.signal('edu.duke.linux.yum')
    def UpdatesAvailableSignal(self, message):
        pass

    @dbus.service.signal('edu.duke.linux.yum')
    def NoUpdatesAvailableSignal(self, message):
        pass
        
    @dbus.service.signal('edu.duke.linux.yum')
    def UpdatesFailedSignal(self, errmsgs):
        pass

    @dbus.service.signal('edu.duke.linux.yum')
    def UpdatesAppliedSignal(self, updinfo):
        pass

    @dbus.service.signal('edu.duke.linux.yum')
    def CheckFailedSignal(self, message):
        pass

    @dbus.service.signal('edu.duke.linux.yum')
    def SetupFailedSignal(self, message, translation_domain=""):
        pass

    @dbus.service.signal('edu.duke.linux.yum')
    def UpdateInfoSignal(self, message):
        pass

    @dbus.service.signal('edu.duke.linux.yum')
    def LockedSignal(self, message):
        pass

class UpdatesDaemon(yum.YumBase):
    def __init__(self, options):
        yum.YumBase.__init__(self)
        self.options = options

        self.emitters = []
        if self.options.dbus:
            self.emitters.append(DbusUpdateEmitter())
        if self.options.email:
            self.emitters.append(EmailUpdateEmitter(self.options.emailfrom,
                                                    self.options.emailto,
                                                    self.options.smtpserver,
                                                    self.options.sendmail))
        if self.options.syslog:
            self.emitters.append(SyslogUpdateEmitter(self.options.logfacility,
                                                     self.options.logident,
                                                     self.options.loglevel))
        if self.options.debug:
            self.emitters.append(StderrUpdateEmitter())
        self.updateInfo = []

    def doSetup(self):
        try:
            self.doConfigSetup(fn=self.options.config)
        except Exception, e:
            syslog.syslog(syslog.LOG_WARNING,
                          "error initializing: %s" % e)

            if isinstance(e, yum.plugins.PluginYumExit):
                self.emitSetupFailed(e.value, e.translation_domain)
            else:
                # if we don't know where the string is from, then assume
                # it's not marked for translation (versus sending 
                # gettext.textdomain() and assuming it's from the default
                # domain for this app)
                self.emitSetupFailed(str(e))
        # Override the metadata expire, because there's no point running 
        # yum-updatesd-helper if we use cached metadata
        for repo in self.repos.listEnabled():
            repo.metadata_expire = 1


    def doLock(self):
        try:
            yum.YumBase.doLock(self, YUM_PID_FILE)
        except yum.Errors.LockError, e:
            self.emitLocked("%s" %(e,))
            sys.exit(1)

    def refreshMetadata(self):
        self.doLock()
        try:
            self.doRepoSetup()
            self.doSackSetup()
            self.doTsSetup()
            self.doRpmDBSetup()
            self.doUpdateSetup()
            try:
                self.doGroupSetup()
            except yum.Errors.GroupsError:
                pass
        except Exception, e:
            syslog.syslog(syslog.LOG_WARNING,
                          "error getting update info: %s" %(e,))
            self.emitCheckFailed("%s" %(e,))
            self.doUnlock(YUM_PID_FILE)
            return False
        return True

    def populateUpdateMetadata(self):
        self.updateMetadata = UpdateMetadata()
        repos = []

        for (new, old) in self.up.getUpdatesTuples():
            pkg = self.getPackageObject(new)
            if pkg.repoid not in repos:
                repo = self.repos.getRepo(pkg.repoid)
                repos.append(repo.id)
                try: # grab the updateinfo.xml.gz from the repodata
                    md = repo.retrieveMD('updateinfo')
                except Exception: # can't find any; silently move on
                    continue
                md = gzip.open(md)
                self.updateMetadata.add(md)
                md.close()

    def populateUpdates(self):
        def getDbusPackageDict(pkg):
            """Returns a dictionary corresponding to the package object
            in the form that we can send over the wire for dbus."""
            pkgDict = {
                    "name": pkg.returnSimple("name"),
                    "version": pkg.returnSimple("version"),
                    "release": pkg.returnSimple("release"),
                    "epoch": pkg.returnSimple("epoch"),
                    "arch": pkg.returnSimple("arch"),
                    "sourcerpm": pkg.returnSimple("sourcerpm"),
                    "summary": pkg.returnSimple("summary") or "",
            }

            # check if any updateinfo is available
            md = self.updateMetadata.get_notice((pkg.name, pkg.ver, pkg.rel))
            if md:
                # right now we only want to know if it is a security update
                pkgDict['type'] = md['type']

            return pkgDict

        if self.up is None:
            # we're _only_ called after updates are setup
            return

        self.populateUpdateMetadata()

        self.updateInfo = []
        for (new, old) in self.up.getUpdatesTuples():
            updating = self.getPackageObject(new)
            updated = self.rpmdb.searchPkgTuple(old)[0]
                
            self.tsInfo.addUpdate(updating, updated)
            
            n = getDbusPackageDict(updating)
            o = getDbusPackageDict(updated)
            self.updateInfo.append((n, o))

        if self.conf.obsoletes:
            for (obs, inst) in self.up.getObsoletesTuples():
                obsoleting = self.getPackageObject(obs)
                installed = self.rpmdb.searchPkgTuple(inst)[0]
                
                self.tsInfo.addObsoleting(obsoleting, installed)
                self.tsInfo.addObsoleted(installed, obsoleting)
                
                n = getDbusPackageDict(obsoleting)
                o = getDbusPackageDict(installed)
                self.updateInfo.append((n, o))

    def refreshUpdates(self):
        try:
            self.populateUpdates()
        except Exception, e:
            self.emitCheckFailed("%s" %(e,))
            return False
        return True

    def findDeps(self):
        try:
            (res, resmsg) = self.buildTransaction()
        except yum.Errors.RepoError, e:
            self.emitCheckFailed("%s" %(e,))
            return False
        if res != 2:
            self.emitUpdateFailed("Failed to build transaction: %s" %(str.join("\n", resmsg),))
            return False
        return True

    def downloadUpdates(self):
        dlpkgs = map(lambda x: x.po, filter(lambda txmbr:
                                            txmbr.ts_state in ("i", "u"),
                                            self.tsInfo.getMembers()))
        try:
            self.downloadPkgs(dlpkgs)
        except (yum.Errors.RepoError, IndexError), e:
            self.emitCheckFailed("%s" %(e,))
            return False

        return True

    def installUpdates(self):
        dlpkgs = map(lambda x: x.po, filter(lambda txmbr:
                                            txmbr.ts_state in ("i", "u"),
                                            self.tsInfo.getMembers()))
        
        for po in dlpkgs:
            result, err = self.sigCheckPkg(po)
            if result == 0:
                continue
            elif result == 1:
                try:
                    self.getKeyForPackage(po)
                except yum.Errors.YumBaseError, errmsg:
                    self.emitUpdateFailed([str(errmsg)])
                    return False

        del self.ts
        self.initActionTs() # make a new, blank ts to populate
        self.populateTs(keepold=0)
        self.ts.check() #required for ordering
        self.ts.order() # order
        cb = callback.RPMInstallCallback(output = 0)
        cb.filelog = True
            
        cb.tsInfo = self.tsInfo
        try:
            self.runTransaction(cb=cb)
        except yum.Errors.YumBaseError, err:
            self.emitUpdateFailed([str(err)])
            return False

        self.emitUpdateApplied()
        return True

    def releaseLocks(self):
        self.closeRpmDB()
        self.doUnlock(YUM_PID_FILE)

    def emitAvailable(self):
        """method to emit a notice about updates"""
        map(lambda x: self.emitUpdateInfo(x), self.updateInfo)
        map(lambda x: x.updatesAvailable(self.updateInfo), self.emitters)

    def emitDownloading(self):
        """method to emit a notice about updates downloading"""
        map(lambda x: x.updatesDownloading(self.updateInfo), self.emitters)

    def emitInstalling(self):
        """method to emit a notice about updates installing"""
        map(lambda x: x.updatesInstalling(self.updateInfo), self.emitters)

    def emitUpdateApplied(self):
        """method to emit a notice when automatic updates applied"""
        map(lambda x: x.updatesApplied(self.updateInfo), self.emitters)

    def emitUpdateFailed(self, errmsgs):
        """method to emit a notice when automatic updates failed"""
        map(lambda x: x.updatesFailed(errmsgs), self.emitters)

    def emitCheckFailed(self, error):
        """method to emit a notice when checking for updates failed"""
        map(lambda x: x.checkFailed(error), self.emitters)

    def emitSetupFailed(self, error, translation_domain=""):
        """method to emit a notice when checking for updates failed"""
        map(lambda x: x.setupFailed(error, translation_domain), self.emitters)

    def emitUpdateInfo(self, update):
        """method to emit information about an available update"""
        map(lambda x: x.updateInfo(update), self.emitters)

    def emitLocked(self, error):
        """method to emit to say that something has the yum lock"""
        map(lambda x: x.locked(error), self.emitters)


def main(options = None):
    if options is None:
        parser = OptionParser()
        # options around what actions to do
        parser.add_option("-c", "--check", action="store_true", default=False, dest="check")
        parser.add_option("-d", "--download", action="store_true", default=False, dest="download")
        parser.add_option("", "--deps", action="store_true", default=False, dest="finddeps")        
        parser.add_option("-a", "--apply", action="store_true", default=False, dest="apply")
        parser.add_option("", "--network-fail", action="store_true", default=False, dest="netfail")        
        # debugging, different configs
        parser.add_option("", "--debug", action="store_true", default=False, dest="debug")
        parser.add_option("", "--config", type="string", default="/etc/yum.conf", dest="config")

        ## options for how to emit and config for each
        parser.add_option("", "--dbus", action="store_true", default=False, dest="dbus")

        # syslog
        parser.add_option("", "--syslog", action="store_true", default=False, dest="syslog")
        parser.add_option("", "--syslog-facility", type="string", default="DAEMON", dest="logfacility")
        parser.add_option("", "--syslog-level", type="string", default="WARN", dest="loglevel")
        parser.add_option("", "--syslog-ident", type="string", default="yum-updatesd", dest="logident")                        

        # email
        parser.add_option("", "--email", action="store_true", default=False, dest="email")
        parser.add_option("", "--email-from", type="string", default="root", dest="emailfrom")
        parser.add_option("", "--email-to", type="string", default="root", dest="emailto")                
        parser.add_option("", "--smtp-server", type="string", default="localhost:25", dest="smtpserver")
        parser.add_option("", "--sendmail", action="store_true", default=False, dest="sendmail")
        
        (options, args) = parser.parse_args()


    syslog.openlog("yum-updatesd-helper", 0, syslog.LOG_DAEMON)

    updd = UpdatesDaemon(options)

    if options.netfail:
        updd.emitCheckFailed("No network available")
        sys.exit(1)

    updd.doSetup()
    if not updd.refreshMetadata():
        sys.exit(1)

    # set up some reasonable things
    if options.apply:
        options.download = options.finddeps = options.check = True
    if options.finddeps:
        options.download = options.check = True
    if options.download:
        options.check = True

    # do the check if so configured
    if not options.check:
        sys.exit(0)
    if not updd.refreshUpdates():
        sys.exit(1)

    # download if set up to do so, else tell about the updates and exit
    if not options.download or len(updd.updateInfo) == 0:
        updd.emitAvailable()
        updd.releaseLocks()
        sys.exit(0)

    updd.emitDownloading()
    if options.finddeps:
        if not updd.findDeps():
            sys.exit(1)
            
    if not updd.downloadUpdates():
        sys.exit(1)

    # now apply if we're set up to do so; else just tell that things are
    # available
    if not options.apply:
        updd.emitAvailable()
        updd.releaseLocks()
        sys.exit(0)

    if not updd.installUpdates():
        sys.exit(1)

    sys.exit(0)

if __name__ == "__main__":
    main()