#!/usr/bin/python # encoding: utf-8 # # Copyright 2009-2010 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 stat import subprocess import sys import time import traceback # Do not place any imports with ObjC bindings above this! try: from Foundation import NSDate 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, output = utils.runExternalScript(script) print >> sys.stderr, result, output 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', '-d', '4'] proc = subprocess.Popen(cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (output, unused_err) = proc.communicate() ioreglines = str(output).splitlines() for line in ioreglines: if 'Idle' in line: parts = line.split() return int(int(parts[3])/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.""" try: plist = FoundationPlist.readPlist( munkicommon.MANAGED_INSTALLS_PLIST_PATH) if plist: if 'LastNotifiedDate' in plist: cmd = ['/usr/bin/defaults', 'delete', munkicommon.MANAGED_INSTALLS_PLIST_PATH_NO_EXT, 'LastNotifiedDate'] unused_retcode = subprocess.call(cmd) except FoundationPlist.NSPropertyListSerializationException: pass 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. """ managedinstallprefs = munkicommon.prefs() ManagedInstallDir = managedinstallprefs['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_forced=False): """Perform our installation/removal tasks. Args: only_forced: Boolean. If True, only do forced installs/removals. Returns: Boolean. True if a restart is required, False otherwise. """ if not only_forced: # 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_forced=only_forced) 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_forced: appleupdates.clearAppleUpdateInfo() elif ((munkicommon.pref('InstallAppleSoftwareUpdates') or applesoftwareupdatesonly) and not only_forced): # are we supposed to handle Apple Software Updates? try: need_to_restart = appleupdates.installAppleUpdates() except: munkicommon.display_error('Unexpected error in ' ' installAppleUpdates:') munkicommon.display_error(traceback.format_exc()) munkicommon.savereport() exit(-1) munkicommon.savereport() return need_to_restart 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) if not munkicommon.currentGUIusers(): # no-one is logged in and 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() cmd = ['/usr/bin/defaults', 'write', munkicommon.MANAGED_INSTALLS_PLIST_PATH_NO_EXT, 'LastCheckDate', '-date', str(now)] unused_retcode = subprocess.call(cmd) cmd = ['/usr/bin/defaults', 'write', munkicommon.MANAGED_INSTALLS_PLIST_PATH_NO_EXT, 'LastCheckResult', '-int', str(result)] unused_retcode = subprocess.call(cmd) def notifyUserOfUpdates(): """Notify the logged-in user of available updates. 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 now.timeIntervalSinceDate_(nextNotifyDate) > 0: # record current notification date cmd = ['/usr/bin/defaults', 'write', munkicommon.MANAGED_INSTALLS_PLIST_PATH_NO_EXT, 'LastNotifiedDate', '-date', str(now)] unused_retcode = subprocess.call(cmd) # Kill Managed Software Update.app if it's already # open so it will update its display # using subprocess.Popen instead of subprocess.call # so stderr doesn't get output to the terminal # when there is no Managed Software Update process running cmd = ['/usr/bin/killall', 'Managed Software Update'] proc = subprocess.Popen(cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (unused_output, unused_err) = proc.communicate() # 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(0.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('--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: 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) # run the preflight script if it exists preflightscript = os.path.join(scriptdir, 'preflight') try: result, output = utils.runExternalScript(preflightscript, runtype) munkicommon.display_info(output) 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) # 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() exit(-1) # Force a prefs refresh, in case preflight modified the prefs file. munkicommon.prefs(force_refresh=True) # set munkicommon globals munkicommon.munkistatusoutput = options.munkistatusoutput munkicommon.verbose = options.verbose # 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 munkistatus.getStopButtonState() == 1: break time.sleep(0.5) munkistatus.quit() else: print >> sys.stderr, \ 'Another instance of %s is running. Exiting.' % myname exit(0) applesoftwareupdatesonly = munkicommon.pref('AppleSoftwareUpdatesOnly') 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() exit(-1) # reset our errors and warnings files, rotate main log if needed munkicommon.reset_errors() munkicommon.reset_warnings() munkicommon.rotate_main_log() munkicommon.archive_report() # start a new report munkicommon.report['StartTime'] = munkicommon.format_time() munkicommon.report['RunType'] = runtype munkicommon.log("### Starting managedsoftwareupdate run ###") if options.verbose: print 'Managed Software Update Tool' print 'Copyright 2010 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() 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): try: if appleupdates.appleSoftwareUpdatesAvailable( forcecheck=(options.manualcheck or runtype == 'checkandinstallatstartup')): updatesavailable = True 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 (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: if appleupdates.appleSoftwareUpdatesAvailable(suppresscheck=True): updatesavailable = True except: munkicommon.display_error('Unexpected error in appleupdates:') munkicommon.display_error(traceback.format_exc()) munkicommon.savereport() exit(-1) mustrestart = False if options.manualcheck: # just quit munkistatus; Managed Software Update will notify munkistatus.quit() elif updatesavailable: 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 doInstallTasks(only_forced=True) # it's possible that we no longer have any available updates # so we need to check InstallInfo.plist again if 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 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') try: unused_r, output = utils.runExternalScript(postflightscript, runtype) munkicommon.display_info(output) 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 if munkicommon.tmpdir: munkicommon.cleanUpTmpDir() if mustrestart: doRestart() 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()