#!/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 sys import os import optparse import subprocess import time import traceback from Foundation import NSDate from munkilib import munkicommon from munkilib import updatecheck from munkilib import installer from munkilib import munkistatus from munkilib import appleupdates from munkilib import FoundationPlist def getIdleSeconds(): '''Gets the number of seconds since the last mouse or keyboard event''' cmd = ['/usr/sbin/ioreg', '-c', 'IOHIDSystem', '-d', '4'] p = subprocess.Popen(cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (output, err) = p.communicate() ioreglines = 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. cmd = ['/sbin/ifconfig', "-a", "inet"] p = subprocess.Popen(cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (output, err) = p.communicate() lines = 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(): try: pl = FoundationPlist.readPlist( "/Library/Preferences/ManagedInstalls.plist") if pl: if 'LastNotifiedDate' in pl: cmd = ['/usr/bin/defaults', 'delete', '/Library/Preferences/ManagedInstalls', 'LastNotifiedDate'] retcode = subprocess.call(cmd) except FoundationPlist.NSPropertyListSerializationException: pass def createDirsIfNeeded(dirlist): 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(): 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(): # 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() 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 appleupdates.clearAppleUpdateInfo() elif munkicommon.pref('InstallAppleSoftwareUpdates'): # 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(): 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: print restartMessage sys.stdout.flush() if not munkicommon.currentGUIusers(): # no-one is logged in and we're at the loginwindow time.sleep(5) retcode = subprocess.call(["/sbin/shutdown", "-r", "now"]) else: if munkicommon.munkistatusoutput: # someone is logged in and we're using munkistatus print "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(): updatesavailable = False installinfo = os.path.join(munkicommon.pref('ManagedInstallDir'), 'InstallInfo.plist') if os.path.exists(installinfo): try: pl = FoundationPlist.readPlist(installinfo) updatesavailable = len(pl.get('removals',[])) or \ len(pl.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', '/Library/Preferences/ManagedInstalls', 'LastCheckDate', '-date', str(now)] retcode = subprocess.call(cmd) cmd = ['/usr/bin/defaults', 'write', '/Library/Preferences/ManagedInstalls', 'LastCheckResult', '-int', str(result)] retcode = subprocess.call(cmd) return def notifyUserOfUpdates(): # called when options.auto == True # someone is logged in, and we have updates. # if we haven't notified in a while, notify: 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', '/Library/Preferences/ManagedInstalls', 'LastNotifiedDate', '-date', str(now)] retcode = subprocess.call(cmd) # Kill Managed Software Update.app if it's already # open so it will update its display cmd = ["/usr/bin/killall", "Managed Software Update"] retcode = subprocess.call(cmd) # 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] retcode = subprocess.call(cmd) time.sleep(0.1) if os.path.exists(launchfile): os.unlink(launchfile) def runPreOrPostFlightScript(script, runtype="custom"): if os.path.exists(script): if os.access(script, os.X_OK): munkicommon.log("Running %s with runtype: %s..." % (script, runtype)) cmd = [script, runtype] p = subprocess.Popen(cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (output, err) = p.communicate() if output: munkicommon.display_info(output) if err: munkicommon.display_info(output) return p.returncode else: munkicommon.display_warning("%s not executable" % script) return 0 def 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 f in flagfiles: if os.path.exists(f): user_triggered = True if f == 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 x in range(5): if networkUp(): break time.sleep(2) else: # delete triggerfile if _not_ checkandinstallatstartup os.unlink(f) 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") result = runPreOrPostFlightScript(preflightscript, runtype) 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) # 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) if not options.installonly: # 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'] = time.ctime() munkicommon.report['RunType'] = runtype if options.verbose: print "Managed Software Update Tool" print "Copyright 2010 The Munki Project" print "http://code.google.com/p/munki\n" updatecheckresult = None if not options.installonly: try: updatecheckresult = updatecheck.check(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 (options.auto or options.manualcheck): if not updatesavailable and not options.installonly: # if there are no munki updates, # are we supposed to check for and install Apple Software Updates? if munkicommon.pref('InstallAppleSoftwareUpdates'): if options.munkistatusoutput: munkistatus.message("Checking for available " "Apple Software Updates...") munkistatus.detail("") munkistatus.percent(-1) else: munkicommon.display_status("Checking for available " "Apple Software Updates...") 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'): # 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) if options.auto: # when --auto, munkistatusoutput is false for checking, # but true for installing munkicommon.munkistatusoutput = True mustrestart = False if options.manualcheck: # just quit munkistatus; Managed Software Update will notify munkistatus.quit() elif updatesavailable: if options.installonly: # just install mustrestart = doInstallTasks() elif options.auto: if not munkicommon.currentGUIusers(): # no GUI users if getIdleSeconds() > 10: if not munkicommon.pref('SuppressAutoInstall') or \ runtype == "checkandinstallatstartup": # no GUI users, system is idle, so install mustrestart = doInstallTasks() else: munkicommon.log("Skipping auto install because " "SuppressAutoInstall is true.") else: # there are GUI users 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 munkicommon.pref('SuppressUserNotification'): # notify the current console user 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) # finish our report munkicommon.report['EndTime'] = time.ctime() 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") result = runPreOrPostFlightScript(postflightscript, runtype) # 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() and getIdleSeconds() > 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'] retcode = subprocess.call(cmd) if __name__ == '__main__': main()