#!/usr/bin/python # encoding: utf-8 # # Copyright 2009-2011 Greg Neagle. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an 'AS IS' BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ managedsoftwareupdate """ import grp import optparse import os import re import stat import subprocess import sys import time import traceback # Do not place any imports with ObjC bindings above this! try: from Foundation import NSDate from Foundation import NSDistributedNotificationCenter from Foundation import NSNotificationDeliverImmediately from Foundation import NSNotificationPostToAllSessions except: # Python is missing ObjC bindings. Run external report script. from munkilib import utils print >> sys.stderr, 'Python is missing ObjC bindings.' scriptdir = os.path.realpath(os.path.dirname(sys.argv[0])) script = os.path.join(scriptdir, 'report_broken_client') try: result, stdout, stderr = utils.runExternalScript(script) print >> sys.stderr, result, stdout, stderr except utils.ScriptNotFoundError: pass # script is not required, so pass except utils.RunExternalScriptError, e: print >> sys.stderr, str(e) sys.exit(1) from munkilib import munkicommon from munkilib import updatecheck from munkilib import installer from munkilib import munkistatus from munkilib import appleupdates from munkilib import FoundationPlist from munkilib import utils def getIdleSeconds(): """Returns the number of seconds since the last mouse or keyboard event.""" cmd = ['/usr/sbin/ioreg', '-c', 'IOHIDSystem'] proc = subprocess.Popen(cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (output, unused_err) = proc.communicate() ioreglines = str(output).splitlines() idle_time = 0 regex = re.compile('"?HIDIdleTime"?\s+=\s+(\d+)') for line in ioreglines: idle_re = regex.search(line) if idle_re: idle_time = idle_re.group(1) break return int(int(idle_time)/1000000000) def networkUp(): """Determine if the network is up by looking for any non-loopback internet network interfaces. Returns: Boolean. True if loopback is found (network is up), False otherwise. """ cmd = ['/sbin/ifconfig', '-a', 'inet'] proc = subprocess.Popen(cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (output, unused_err) = proc.communicate() lines = str(output).splitlines() for line in lines: if 'inet' in line: parts = line.split() addr = parts[1] if not addr in ['127.0.0.1', '0.0.0.0.0']: return True return False def clearLastNotifiedDate(): """Clear the last date the user was notified of updates.""" munkicommon.set_pref('LastNotifiedDate', None) def createDirsIfNeeded(dirlist): """Create any missing directories needed by the munki tools. Args: dirlist: a sequence of directories. Returns: Boolean. True if all directories existed or were created, False otherwise. """ for directory in dirlist: if not os.path.exists(directory): try: os.mkdir(directory) except (OSError, IOError): print >> sys.stderr, 'ERROR: Could not create %s' % directory return False return True def initMunkiDirs(): """Figure out where data directories should be and create them if needed. Returns: Boolean. True if all data dirs existed or were created, False otherwise. """ ManagedInstallDir = munkicommon.pref('ManagedInstallDir') manifestsdir = os.path.join(ManagedInstallDir, 'manifests') catalogsdir = os.path.join(ManagedInstallDir, 'catalogs') cachedir = os.path.join(ManagedInstallDir, 'Cache') logdir = os.path.join(ManagedInstallDir, 'Logs') if not createDirsIfNeeded([ManagedInstallDir, manifestsdir, catalogsdir, cachedir, logdir]): munkicommon.display_error('Could not create needed directories ' 'in %s' % ManagedInstallDir) return False else: return True def doInstallTasks(only_unattended=False): """Perform our installation/removal tasks. Args: only_unattended: Boolean. If True, only do unattended_(un)install items. Returns: Boolean. True if a restart is required, False otherwise. """ if not only_unattended: # first, clear the last notified date # so we can get notified of new changes after this round # of installs clearLastNotifiedDate() need_to_restart = False # munki updates take priority over Apple Updates, because # a munki install or (especially) removal could make a # pending Apple update no longer necessary or even complicate # or prevent the removal of another item. # Therefore we only install Apple updates if there are no # pending munki updates. if munkiUpdatesAvailable(): # install munki updates try: need_to_restart = installer.run(only_unattended=only_unattended) except: munkicommon.display_error( 'Unexpected error in munkilib.installer:') munkicommon.display_error(traceback.format_exc()) munkicommon.savereport() exit(-1) # clear any Apple update info since it may no longer # be relevant if not only_unattended: appleupdates.clearAppleUpdateInfo() elif ((munkicommon.pref('InstallAppleSoftwareUpdates') or munkicommon.pref('AppleSoftwareUpdatesOnly')) and not only_unattended): # are we supposed to handle Apple Software Updates? try: need_to_restart = appleupdates.installAppleUpdates() except: munkicommon.display_error( 'Unexpected error in appleupdates.installAppleUpdates:') munkicommon.display_error(traceback.format_exc()) munkicommon.savereport() exit(-1) munkicommon.savereport() return need_to_restart def startLogoutHelper(): """Handle the need for a forced logout. Start our logouthelper""" cmd = ['/bin/launchctl', 'start', 'com.googlecode.munki.logouthelper'] result = subprocess.call(cmd) if result: # some problem with the launchd job munkicommon.display_error( 'Could not start com.googlecode.munki.logouthelper') def doRestart(): """Handle the need for a restart.""" restartMessage = 'Software installed or removed requires a restart.' munkicommon.log(restartMessage) if munkicommon.munkistatusoutput: munkistatus.hideStopButton() munkistatus.message(restartMessage) munkistatus.detail('') munkistatus.percent(-1) else: munkicommon.display_info(restartMessage) # TODO: temporary fix for forced logout problem where we've killed # loginwindow sessions, but munkicommon.currentGUIusers() still returns # users. Need to find a better solution, though. #if not munkicommon.currentGUIusers(): # # no-one is logged in and we're at the loginwindow consoleuser = munkicommon.getconsoleuser() if not consoleuser or consoleuser == u'loginwindow': # no-one is logged in or we're at the loginwindow time.sleep(5) unused_retcode = subprocess.call(['/sbin/shutdown', '-r', 'now']) else: if munkicommon.munkistatusoutput: # someone is logged in and we're using munkistatus munkicommon.display_info( 'Notifying currently logged-in user to restart.') munkistatus.activate() munkistatus.restartAlert() munkicommon.osascript( 'tell application "System Events" to restart') else: print 'Please restart immediately.' def munkiUpdatesAvailable(): """Return True if there are available updates, False otherwise.""" updatesavailable = False installinfo = os.path.join(munkicommon.pref('ManagedInstallDir'), 'InstallInfo.plist') if os.path.exists(installinfo): try: plist = FoundationPlist.readPlist(installinfo) updatesavailable = len(plist.get('removals', [])) or \ len(plist.get('managed_installs', [])) except (AttributeError, FoundationPlist.NSPropertyListSerializationException): munkicommon.display_error('Install info at %s is invalid.' % installinfo) return updatesavailable def recordUpdateCheckResult(result): """Record last check date and result""" now = NSDate.new() munkicommon.set_pref('LastCheckDate', now) munkicommon.set_pref('LastCheckResult', result) def sendUpdateNotification(): '''Sends an update notification via NSDistributedNotificationCenter MSU.app registers to receive these events.''' dnc = NSDistributedNotificationCenter.defaultCenter() dnc.postNotificationName_object_userInfo_options_( 'com.googlecode.munki.ManagedSoftwareUpdate.update', None, None, NSNotificationDeliverImmediately + NSNotificationPostToAllSessions) def notifyUserOfUpdates(force=False): """Notify the logged-in user of available updates. Args: force: bool, default False, forcefully notify user regardless of LastNotifiedDate. Returns: Boolean. True if the user was notified, False otherwise. """ # called when options.auto == True # someone is logged in, and we have updates. # if we haven't notified in a while, notify: user_was_notified = False lastNotifiedString = munkicommon.pref('LastNotifiedDate') daysBetweenNotifications = munkicommon.pref('DaysBetweenNotifications') now = NSDate.new() nextNotifyDate = now if lastNotifiedString: lastNotifiedDate = NSDate.dateWithString_(lastNotifiedString) interval = daysBetweenNotifications * (24 * 60 * 60) if daysBetweenNotifications > 0: # we make this adjustment so a 'daily' notification # doesn't require 24 hours to elapse # subtract 6 hours interval = interval - (6 * 60 * 60) nextNotifyDate = lastNotifiedDate.dateByAddingTimeInterval_(interval) if force or now.timeIntervalSinceDate_(nextNotifyDate) >= 0: # record current notification date munkicommon.set_pref('LastNotifiedDate', now) # notify user of available updates using LaunchAgent to start # Managed Software Update.app in the user context. launchfile = '/var/run/com.googlecode.munki.ManagedSoftwareUpdate' cmd = ['/usr/bin/touch', launchfile] unused_retcode = subprocess.call(cmd) time.sleep(1) if os.path.exists(launchfile): os.unlink(launchfile) user_was_notified = True return user_was_notified def main(): """Main""" # check to see if we're root if os.geteuid() != 0: print >> sys.stderr, 'You must run this as root!' exit(-1) # save this for later scriptdir = os.path.realpath(os.path.dirname(sys.argv[0])) p = optparse.OptionParser() p.set_usage("""Usage: %prog [options]""") p.add_option('--auto', '-a', action='store_true', help="""Used by launchd LaunchAgent for scheduled runs. No user feedback or intervention. All other options ignored.""") p.add_option('--logoutinstall', '-l', action='store_true', help="""Used by launchd LaunchAgent when running at the loginwindow.""") p.add_option('--installwithnologout', action='store_true', help="""Used by Managed Software Update.app when user triggers an install without logging out.""") p.add_option('--manualcheck', action='store_true', help="""Used by launchd LaunchAgent when checking manually.""") p.add_option('--munkistatusoutput', '-m', action='store_true', help="""Uses MunkiStatus.app for progress feedback when installing.""") p.add_option('--id', default='', help='Alternate identifier for catalog retreival') p.add_option('--quiet', '-q', action='store_true', help="""Quiet mode. Logs messages, but nothing to stdout. --verbose is ignored if --quiet is used.""") p.add_option('--verbose', '-v', action='count', default=1, help="""More verbose output. May be specified multiple times.""") p.add_option('--checkonly', action='store_true', help="""Check for updates, but don't install them. This is the default behavior.""") p.add_option('--installonly', action='store_true', help='Skip checking and install any pending updates.') p.add_option('--applesuspkgsonly', action='store_true', help=('Only check/install Apple SUS packages, ' 'skip Munki packages.')) p.add_option('--munkipkgsonly', action='store_true', help=('Only check/install Munki packages, ' 'skip Apple SUS.')) p.add_option('--version', '-V', action='store_true', help='Print the version of the munki tools and exit.') options, arguments = p.parse_args() runtype = 'custom' checkandinstallatstartupflag = \ '/Users/Shared/.com.googlecode.munki.checkandinstallatstartup' installatstartupflag = \ '/Users/Shared/.com.googlecode.munki.installatstartup' installatlogoutflag = '/private/tmp/com.googlecode.munki.installatlogout' if options.version: print munkicommon.get_version() exit(0) if options.auto: # typically invoked by a launch daemon periodically. # munkistatusoutput is false for checking, but true for installing runtype = 'auto' options.munkistatusoutput = False options.quiet = True options.checkonly = False options.installonly = False if options.logoutinstall: # typically invoked by launchd agent # running in the LoginWindow context runtype = 'logoutinstall' options.munkistatusoutput = True options.quiet = True options.checkonly = False options.installonly = True # if we're running at the loginwindow, # let's make sure the user triggered # the update before logging out, or we triggered it before restarting. user_triggered = False flagfiles = [checkandinstallatstartupflag, installatstartupflag, installatlogoutflag] for filename in flagfiles: if os.path.exists(filename): user_triggered = True if filename == checkandinstallatstartupflag: runtype = 'checkandinstallatstartup' options.installonly = False options.auto = True # HACK: sometimes this runs before the network is up. # we'll attempt to wait up to 10 seconds for the # network interfaces to come up # before continuing munkicommon.display_status('Waiting for network...') for i in range(5): if networkUp(): break time.sleep(2) else: # delete triggerfile if _not_ checkandinstallatstartup os.unlink(filename) if not user_triggered: munkicommon.cleanUpTmpDir() exit(0) if options.installwithnologout: # typically invoked by Managed Software Update.app # by user who decides not to logout launchdtriggerfile = \ '/private/tmp/.com.googlecode.munki.managedinstall.launchd' if os.path.exists(launchdtriggerfile): # remove it so we aren't automatically relaunched os.unlink(launchdtriggerfile) runtype = 'installwithnologout' options.munkistatusoutput = True options.quiet = True options.checkonly = False options.installonly = True if options.manualcheck: # triggered by Managed Software Update.app launchdtriggerfile = \ '/private/tmp/.com.googlecode.munki.updatecheck.launchd' if os.path.exists(launchdtriggerfile): # remove it so we aren't automatically relaunched os.unlink(launchdtriggerfile) runtype = 'manualcheck' options.munkistatusoutput = True options.quiet = True options.checkonly = True options.installonly = False if options.quiet: options.verbose = 0 if options.checkonly and options.installonly: print >> sys.stderr, \ '--checkonly and --installonly options are mutually exclusive!' exit(-1) # set munkicommon globals munkicommon.munkistatusoutput = options.munkistatusoutput munkicommon.verbose = options.verbose # run the preflight script if it exists preflightscript = os.path.join(scriptdir, 'preflight') if os.path.exists(preflightscript): munkicommon.display_status('Performing preflight tasks...') try: result, stdout, stderr = utils.runExternalScript( preflightscript, runtype) if stdout: munkicommon.display_info('preflight stdout: %s', stdout) if stderr: munkicommon.display_info('preflight stderr: %s', stderr) except utils.ScriptNotFoundError: result = 0 pass # script is not required, so pass except utils.RunExternalScriptError, e: result = 0 munkicommon.display_warning(str(e)) if result: # non-zero return code means don't run munkicommon.display_info( 'managedsoftwareupdate run aborted by preflight script: %s' % result) munkicommon.display_info(stderr) # record the check result for use by Managed Software Update.app # right now, we'll return the same code as if the munki server # was unavailable. We need to revisit this and define additional # update check results. recordUpdateCheckResult(-2) if options.munkistatusoutput: # connect to socket and quit munkistatus.activate() munkistatus.quit() munkicommon.cleanUpTmpDir() exit(-1) # Force a prefs refresh, in case preflight modified the prefs file. munkicommon.reload_prefs() # create needed directories if necessary if not initMunkiDirs(): exit(-1) # check to see if another instance of this script is running myname = os.path.basename(sys.argv[0]) if munkicommon.pythonScriptRunning(myname): # another instance of this script is running, so we should quit if options.manualcheck: # a manual update check was triggered # (probably by Managed Software Update), but managedsoftwareupdate # is already running. We should provide user feedback munkistatus.activate() munkistatus.message('Checking for available updates...') while True: # loop til the other instance exits if not munkicommon.pythonScriptRunning(myname): break # or user clicks Stop if munkicommon.stopRequested(): break time.sleep(0.5) munkistatus.quit() else: print >> sys.stderr, \ 'Another instance of %s is running. Exiting.' % myname munkicommon.cleanUpTmpDir() exit(0) applesoftwareupdatesonly = (munkicommon.pref('AppleSoftwareUpdatesOnly') or options.applesuspkgsonly) if not options.installonly and not applesoftwareupdatesonly: # check to see if we can talk to the manifest server server = munkicommon.pref('ManifestURL') or \ munkicommon.pref('SoftwareRepoURL') result = updatecheck.checkServer(server) if result != (0, 'OK'): munkicommon.display_error( 'managedsoftwareupdate: server check for %s failed: %s' % (server, str(result))) if options.manualcheck: # record our result recordUpdateCheckResult(-1) # connect to socket and quit munkistatus.activate() munkistatus.quit() munkicommon.cleanUpTmpDir() exit(-1) # reset our errors and warnings files, rotate main log if needed munkicommon.reset_errors() munkicommon.reset_warnings() munkicommon.rotate_main_log() if options.installonly: # we're only installing, not checking, so we should copy # some report values from the prior run munkicommon.readreport() # archive the previous session's report munkicommon.archive_report() # start a new report munkicommon.report['StartTime'] = munkicommon.format_time() munkicommon.report['RunType'] = runtype munkicommon.report['Errors'] = [] munkicommon.report['Warnings'] = [] munkicommon.log("### Starting managedsoftwareupdate run ###") if options.verbose: print 'Managed Software Update Tool' print 'Copyright 2010-2011 The Munki Project' print 'http://code.google.com/p/munki\n' if applesoftwareupdatesonly and options.verbose: print ('NOTE: managedsoftwareupdate is configured to process Apple ' 'Software Updates only.') updatecheckresult = None if not options.installonly and not applesoftwareupdatesonly: try: updatecheckresult = updatecheck.check(client_id=options.id) except: munkicommon.display_error('Unexpected error in updatecheck:') munkicommon.display_error(traceback.format_exc()) munkicommon.savereport() exit(-1) if updatecheckresult is not None: recordUpdateCheckResult(updatecheckresult) updatesavailable = munkiUpdatesAvailable() appleupdatesavailable = False if (not updatesavailable and not options.installonly and not munkicommon.stopRequested()): # if there are no munki updates, # are we supposed to check for and install Apple Software Updates? if ((munkicommon.pref('InstallAppleSoftwareUpdates') or applesoftwareupdatesonly) and not options.munkipkgsonly): try: appleupdatesavailable = \ appleupdates.appleSoftwareUpdatesAvailable( forcecheck=(options.manualcheck or runtype == 'checkandinstallatstartup')) except: munkicommon.display_error('Unexpected error in appleupdates:') munkicommon.display_error(traceback.format_exc()) munkicommon.savereport() exit(-1) if (not updatesavailable and options.installonly and not options.munkipkgsonly and (munkicommon.pref('InstallAppleSoftwareUpdates') or applesoftwareupdatesonly)): # just look and see if there are already downloaded Apple updates # to install; don't run softwareupdate to check with Apple try: appleupdatesavailable = \ appleupdates.appleSoftwareUpdatesAvailable(suppresscheck=True) except: munkicommon.display_error('Unexpected error in appleupdates:') munkicommon.display_error(traceback.format_exc()) munkicommon.savereport() exit(-1) # send a notification event so MSU can update its display if needed sendUpdateNotification() mustrestart = False mustlogout = False if options.manualcheck: # just quit munkistatus; Managed Software Update will notify munkistatus.quit() elif updatesavailable or appleupdatesavailable: if options.installonly or options.logoutinstall: # just install mustrestart = doInstallTasks() elif options.auto: if not munkicommon.currentGUIusers(): # no GUI users if getIdleSeconds() > 10: if not munkicommon.pref('SuppressAutoInstall'): # no GUI users, system is idle, so install # enable status output over login window munkicommon.munkistatusoutput = True mustrestart = doInstallTasks() else: munkicommon.log('Skipping auto install because ' 'SuppressAutoInstall is true.') else: munkicommon.log('Skipping auto install because system is ' 'not idle (keyboard or mouse activity).') else: # there are GUI users unused_force_action = updatecheck.checkForceInstallPackages() doInstallTasks(only_unattended=True) force_action = updatecheck.checkForceInstallPackages() # if any installs are still requiring force actions, just # initiate a logout to get started. blocking apps might # have stopped even non-logout/reboot installs from # occuring. if force_action in ['now', 'logout', 'restart']: mustlogout = True # it's possible that we no longer have any available updates # so we need to check InstallInfo.plist again # however Apple Updates have not been affected by the # unattended install tasks (so that check is still valid). if appleupdatesavailable or munkiUpdatesAvailable(): consoleuser = munkicommon.getconsoleuser() if consoleuser == u'loginwindow': # someone is logged in, but we're sitting at # the loginwindow due to fast user switching # so do nothing pass elif force_action: notifyUserOfUpdates(force=True) time.sleep(2) startLogoutHelper() elif not munkicommon.pref('SuppressUserNotification'): notifyUserOfUpdates() else: munkicommon.log('Skipping user notification because ' 'SuppressUserNotification is true.') elif not options.quiet: print ('\nRun %s --installonly to install the downloaded ' 'updates.' % myname) else: # no updates available if options.installonly and not options.quiet: print 'Nothing to install or remove.' if runtype == 'checkandinstallatstartup': # we have nothing to do, so remove the # checkandinstallatstartupflag file # so we'll stop running at startup/logout if os.path.exists(checkandinstallatstartupflag): os.unlink(checkandinstallatstartupflag) munkicommon.log("### Ending managedsoftwareupdate run ###") # finish our report munkicommon.report['EndTime'] = munkicommon.format_time() munkicommon.report['ManagedInstallVersion'] = munkicommon.get_version() munkicommon.report['AvailableDiskSpace'] = \ munkicommon.getAvailableDiskSpace() munkicommon.report['ConsoleUser'] = munkicommon.getconsoleuser() or \ '' munkicommon.savereport() # run the postflight script if it exists postflightscript = os.path.join(scriptdir, 'postflight') if os.path.exists(postflightscript): munkicommon.display_status('Performing postflight tasks...') try: result, stdout, stderr = utils.runExternalScript( postflightscript, runtype) if result: munkicommon.display_info('postflight return code: %d' % result) if stdout: munkicommon.display_info('postflight stdout: %s', stdout) if stderr: munkicommon.display_info('postflight stderr: %s', stderr) except utils.ScriptNotFoundError: pass # script is not required, so pass except utils.RunExternalScriptError, e: munkicommon.display_warning(str(e)) # we ignore the result of the postflight munkicommon.cleanUpTmpDir() if mustrestart: doRestart() #elif mustlogout: # doForcedLogout() # indirectly done via logouthelper elif munkicommon.munkistatusoutput: munkistatus.quit() if runtype == 'checkandinstallatstartup' and not mustrestart: if os.path.exists(checkandinstallatstartupflag): # we installed things but did not need to restart; we need to run # again to check for more updates. if not munkicommon.currentGUIusers(): # no-one is logged in idleseconds = getIdleSeconds() if not idleseconds > 10: # system is not idle, but check again in case someone has # simply briefly touched the mouse to see progress. time.sleep(15) idleseconds = getIdleSeconds() if idleseconds > 10: # no-one is logged in and the machine has been idle # for a few seconds; kill the loginwindow # (which will cause us to run again) munkicommon.log( 'Killing loginwindow so we will run again...') cmd = ['/usr/bin/killall', 'loginwindow'] unused_retcode = subprocess.call(cmd) else: munkicommon.log( 'System not idle -- skipping killing loginwindow') if __name__ == '__main__': main()