Files
munki/code/client/managedsoftwareupdate

691 lines
27 KiB
Python
Executable File

#!/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:
utils.runExternalScript(script)
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(msg)
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 \
'<None>'
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(msg)
# 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']
unused_retcode = subprocess.call(cmd)
if __name__ == '__main__':
main()