Files
munki/code/Managed Software Update/munki.py

556 lines
18 KiB
Python

# encoding: utf-8
#
# munki.py
# Managed Software Update
#
# Created by Greg Neagle on 2/11/10.
# Copyright 2010-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.
'''munki-specific code for use with Managed Software Update'''
import errno
import logging
import os
import stat
import subprocess
import random
import FoundationPlist
import Foundation
from Foundation import NSDate
from Foundation import NSFileManager
from Foundation import CFPreferencesCopyAppValue
from Foundation import CFPreferencesAppSynchronize
INSTALLATLOGOUTFILE = "/private/tmp/com.googlecode.munki.installatlogout"
UPDATECHECKLAUNCHFILE = \
"/private/tmp/.com.googlecode.munki.updatecheck.launchd"
INSTALLWITHOUTLOGOUTFILE = \
"/private/tmp/.com.googlecode.munki.managedinstall.launchd"
MSULOGDIR = \
"/Users/Shared/.com.googlecode.munki.ManagedSoftwareUpdate.logs"
MSULOGFILE = "%s.log"
MSULOGENABLED = False
class FleetingFileHandler(logging.FileHandler):
"""File handler which opens/closes the log file only during log writes."""
def __init__(self, filename, mode='a', encoding=None, delay=True):
if hasattr(self, '_open'): # if py2.6+ ...
logging.FileHandler.__init__(self, filename, mode, encoding, delay)
else:
logging.FileHandler.__init__(self, filename, mode, encoding)
# lots of py <=2.5 fixes to support delayed open and immediate
# close.
self.encoding = encoding
self._open = self.__open
self.flush = self.__flush
self._close()
def __open(self):
"""Open the log file."""
if self.encoding is None:
stream = open(self.baseFilename, self.mode)
else:
stream = logging.codecs.open(
self.baseFilename, self.mode, self.encoding)
return stream
def __flush(self):
"""Flush the stream if it is open."""
if self.stream:
self.stream.flush()
def _close(self):
"""Close the log file if it is open."""
if self.stream:
self.flush()
if hasattr(self.stream, 'close'):
self.stream.close()
self.stream = None
def close(self):
"""Close the entire handler if it is open."""
if self.stream:
return logging.FileHandler.close(self)
def emit(self, record):
"""Open the log, emit a record and close the log."""
if self.stream is None:
self.stream = self._open()
logging.FileHandler.emit(self, record)
self._close()
def call(cmd):
'''Convenience function; works around an issue with subprocess.call
in PyObjC in Snow Leopard'''
proc = subprocess.Popen(cmd, bufsize=1, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(output, err) = proc.communicate()
return proc.returncode
BUNDLE_ID = 'ManagedInstalls'
def reload_prefs():
"""Uses CFPreferencesAppSynchronize(BUNDLE_ID)
to make sure we have the latest prefs. Call this
if another process may have modified ManagedInstalls.plist,
this needs to be run after returning from MunkiStatus"""
CFPreferencesAppSynchronize(BUNDLE_ID)
def pref(pref_name):
"""Return a preference. Since this uses CFPreferencesCopyAppValue,
Preferences can be defined several places. Precedence is:
- MCX
- ~/Library/Preferences/ManagedInstalls.plist
- /Library/Preferences/ManagedInstalls.plist
- default_prefs defined here.
"""
default_prefs = {
'ManagedInstallDir': '/Library/Managed Installs',
'InstallAppleSoftwareUpdates': False,
'ShowRemovalDetail': False,
'InstallRequiresLogout': False
}
pref_value = CFPreferencesCopyAppValue(pref_name, BUNDLE_ID)
if pref_value == None:
pref_value = default_prefs.get(pref_name)
if type(pref_value).__name__ in ['__NSCFDate', '__NSDate', '__CFDate']:
# convert NSDate/CFDates to strings
pref_value = str(pref_value)
return pref_value
def readSelfServiceManifest():
'''Read the SelfServeManifest if it exists'''
# read our working copy if it exists
SelfServeManifest = "/Users/Shared/.SelfServeManifest"
if not os.path.exists(SelfServeManifest):
# no working copy, look for system copy
managedinstallbase = pref('ManagedInstallDir')
SelfServeManifest = os.path.join(managedinstallbase, "manifests",
"SelfServeManifest")
if os.path.exists(SelfServeManifest):
try:
return FoundationPlist.readPlist(SelfServeManifest)
except FoundationPlist.NSPropertyListSerializationException:
return {}
else:
return {}
def writeSelfServiceManifest(optional_install_choices):
'''Write out our self-serve manifest
so managedsoftwareupdate can use it'''
usermanifest = "/Users/Shared/.SelfServeManifest"
try:
FoundationPlist.writePlist(optional_install_choices, usermanifest)
except FoundationPlist.FoundationPlistException:
pass
def getRemovalDetailPrefs():
'''Returns preference to control display of removal detail'''
return pref('ShowRemovalDetail')
def installRequiresLogout():
'''Returns preference to force logout for all installs'''
return pref('InstallRequiresLogout')
def getInstallInfo():
'''Returns the dictionary describing the managed installs and removals'''
managedinstallbase = pref('ManagedInstallDir')
plist = {}
installinfo = os.path.join(managedinstallbase, 'InstallInfo.plist')
if os.path.exists(installinfo):
try:
plist = FoundationPlist.readPlist(installinfo)
except FoundationPlist.NSPropertyListSerializationException:
pass
return plist
def thereAreUpdatesToBeForcedSoon(hours=72):
'''Return True if any updates need to be installed within the next
X hours, false otherwise'''
installinfo = getInstallInfo()
if installinfo:
now = NSDate.date()
now_xhours = NSDate.dateWithTimeIntervalSinceNow_(hours * 3600)
for item in installinfo.get('managed_installs', []):
force_install_after_date = item.get('force_install_after_date')
if force_install_after_date:
force_install_after_date = discardTimeZoneFromDate(
force_install_after_date)
if now_xhours >= force_install_after_date:
return True
return False
def earliestForceInstallDate():
"""Check installable packages for force_install_after_dates
Returns None or earliest force_install_after_date converted to local time
"""
earliest_date = None
installinfo = getInstallInfo()
for install in installinfo.get('managed_installs', []):
this_force_install_date = install.get('force_install_after_date')
if this_force_install_date:
this_force_install_date = discardTimeZoneFromDate(
this_force_install_date)
if not earliest_date or this_force_install_date < earliest_date:
earliest_date = this_force_install_date
return earliest_date
def discardTimeZoneFromDate(the_date):
"""Input: NSDate object
Output: NSDate object with same date and time as the UTC.
In Los Angeles (PDT), '2011-06-20T12:00:00Z' becomes
'2011-06-20 12:00:00 -0700'.
In New York (EDT), it becomes '2011-06-20 12:00:00 -0400'.
"""
# get local offset
offset = the_date.descriptionWithCalendarFormat_timeZone_locale_(
'%z', None, None)
hour_offset = int(offset[0:3])
minute_offset = int(offset[0] + offset[3:])
seconds_offset = 60 * 60 * hour_offset + 60 * minute_offset
# return new NSDate minus local_offset
return the_date.dateByAddingTimeInterval_(-seconds_offset)
def stringFromDate(nsdate):
"""Input: NSDate object
Output: unicode object, date and time formatted per system locale.
"""
df = Foundation.NSDateFormatter.alloc().init()
df.setFormatterBehavior_(Foundation.NSDateFormatterBehavior10_4)
df.setDateStyle_(Foundation.kCFDateFormatterLongStyle)
df.setTimeStyle_(Foundation.kCFDateFormatterShortStyle)
return unicode(df.stringForObjectValue_(nsdate))
def startUpdateCheck():
'''Does launchd magic to run managedsoftwareupdate as root.'''
try:
if not os.path.exists(UPDATECHECKLAUNCHFILE):
open(UPDATECHECKLAUNCHFILE, 'w').close()
return 0
except (OSError, IOError):
return 1
def getAppleUpdates():
'''Returns any available Apple updates'''
managedinstallbase = pref('ManagedInstallDir')
plist = {}
appleUpdatesFile = os.path.join(managedinstallbase, 'AppleUpdates.plist')
if (os.path.exists(appleUpdatesFile) and
pref('InstallAppleSoftwareUpdates')):
try:
plist = FoundationPlist.readPlist(appleUpdatesFile)
except FoundationPlist.NSPropertyListSerializationException:
pass
return plist
def humanReadable(kbytes):
"""Returns sizes in human-readable units."""
units = [(" KB", 2**10), (" MB", 2**20), (" GB", 2**30), (" TB", 2**40)]
for suffix, limit in units:
if kbytes > limit:
continue
else:
return str(round(kbytes/float(limit/2**10), 1)) + suffix
def trimVersionString(version_string):
"""Trims all lone trailing zeros in the version string after major/minor.
Examples:
10.0.0.0 -> 10.0
10.0.0.1 -> 10.0.0.1
10.0.0-abc1 -> 10.0.0-abc1
10.0.0-abc1.0 -> 10.0.0-abc1
"""
if version_string == None or version_string == '':
return ''
version_parts = version_string.split('.')
# strip off all trailing 0's in the version, while over 2 parts.
while len(version_parts) > 2 and version_parts[-1] == '0':
del(version_parts[-1])
return '.'.join(version_parts)
def getconsoleuser():
from SystemConfiguration import SCDynamicStoreCopyConsoleUser
cfuser = SCDynamicStoreCopyConsoleUser( None, None, None )
return cfuser[0]
def currentGUIusers():
'''Gets a list of GUI users by parsing the output of /usr/bin/who'''
gui_users = []
proc = subprocess.Popen("/usr/bin/who", shell=False,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(output, err) = proc.communicate()
lines = str(output).splitlines()
for line in lines:
if "console" in line:
parts = line.split()
gui_users.append(parts[0])
return gui_users
def logoutNow():
'''Uses oscascript to run an AppleScript
to tell loginwindow to logout.
Ugly, but it works.'''
script = """
ignoring application responses
tell application "loginwindow"
«event aevtrlgo»
end tell
end ignoring
"""
cmd = ["/usr/bin/osascript"]
for line in script.splitlines():
line = line.rstrip().lstrip()
if line:
cmd.append("-e")
cmd.append(line)
result = call(cmd)
def logoutAndUpdate():
'''Touch a flag so the process that runs after
logout knows it's OK to install everything'''
try:
if not os.path.exists(INSTALLATLOGOUTFILE):
open(INSTALLATLOGOUTFILE, 'w').close()
logoutNow()
except (OSError, IOError):
return 1
def clearLaunchTrigger():
'''Clear the trigger file that fast-launches us at loginwindow.
typically because we have been launched in statusmode at the
loginwindow to perform a logout-install.'''
try:
if os.path.exists(INSTALLATLOGOUTFILE):
os.unlink(INSTALLATLOGOUTFILE)
except (OSError, IOError):
return 1
def justUpdate():
'''Trigger managedinstaller via launchd KeepAlive path trigger
We touch a file that launchd is is watching
launchd, in turn,
launches managedsoftwareupdate --installwithnologout as root'''
try:
if not os.path.exists(INSTALLWITHOUTLOGOUTFILE):
open(INSTALLWITHOUTLOGOUTFILE, 'w').close()
return 0
except (OSError, IOError):
return 1
def getRunningProcesses():
"""Returns a list of paths of running processes"""
proc = subprocess.Popen(['/bin/ps', '-axo' 'comm='],
shell=False, stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(output, unused_err) = proc.communicate()
if proc.returncode == 0:
proc_list = [item for item in output.splitlines()
if item.startswith('/')]
LaunchCFMApp = ('/System/Library/Frameworks/Carbon.framework'
'/Versions/A/Support/LaunchCFMApp')
if LaunchCFMApp in proc_list:
# we have a really old Carbon app
proc = subprocess.Popen(['/bin/ps', '-axwwwo' 'args='],
shell=False, stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(output, unused_err) = proc.communicate()
if proc.returncode == 0:
carbon_apps = [item[len(LaunchCFMApp)+1:]
for item in output.splitlines()
if item.startswith(LaunchCFMApp)]
if carbon_apps:
proc_list.extend(carbon_apps)
return proc_list
else:
return []
def getRunningBlockingApps(appnames):
"""Given a list of app names, return a list of friendly names
for apps in the list that are running"""
proc_list = getRunningProcesses()
running_apps = []
filemanager = NSFileManager.alloc().init()
for appname in appnames:
matching_items = []
if appname.endswith('.app'):
# search by filename
matching_items = [item for item in proc_list
if '/'+ appname + '/' in item]
else:
# check executable name
matching_items = [item for item in proc_list
if item.endswith('/' + appname)]
if not matching_items:
# try adding '.app' to the name and check again
matching_items = [item for item in proc_list
if '/'+ appname + '.app/' in item]
matching_items = set(matching_items)
for path in matching_items:
while '/Contents/' in path or path.endswith('/Contents'):
path = os.path.dirname(path)
# ask NSFileManager for localized name since end-users
# will see this name
running_apps.append(filemanager.displayNameAtPath_(path))
return list(set(running_apps))
def setupLogging(username=None):
"""Setup logging module.
Args:
username: str, optional, current login name
"""
global MSULOGENABLED
if (logging.root.handlers and
logging.root.handlers[0].__class__ is FleetingFileHandler):
return
if pref('MSULogEnabled'):
MSULOGENABLED = True
if not MSULOGENABLED:
return
if username is None:
username = os.getlogin() or 'UID%d' % os.getuid()
if not os.path.exists(MSULOGDIR):
try:
os.mkdir(MSULOGDIR, 01777)
except OSError, e:
logging.error('mkdir(%s): %s' % (MSULOGDIR, str(e)))
return
if not os.path.isdir(MSULOGDIR):
logging.error('%s is not a directory' % MSULOGDIR)
return
# freshen permissions, if possible.
try:
os.chmod(MSULOGDIR, 01777)
except OSError:
pass
# find a safe log file to write to for this user
filename = os.path.join(MSULOGDIR, MSULOGFILE % username)
t = 0
ours = False
while t < 10:
try:
f = os.open(filename, os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW, 0600)
st = os.fstat(f)
ours = stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid()
os.close(f)
if ours:
break
except (OSError, IOError):
pass # permission denied, symlink, ...
# avoid creating many separate log files by using one static suffix
# as the first alternative. if unsuccessful, switch to totally
# randomly suffixed files.
if t == 0:
random.seed(hash(username))
elif t == 1:
random.seed()
filename = os.path.join(
MSULOGDIR, MSULOGFILE % (
'%s_%d' % (username, random.randint(0, 2**32))))
t += 1
if not ours:
logging.error('No logging is possible')
return
# setup log handler
log_format = '%(created)f %(levelname)s ' + username + ' : %(message)s'
ffh = None
try:
ffh = FleetingFileHandler(filename)
except IOError, e:
logging.error('Error opening log file %s: %s' % (filename, str(e)))
ffh.setFormatter(logging.Formatter(log_format, None))
logging.root.addHandler(ffh)
logging.getLogger().setLevel(logging.INFO)
def log(source, event, msg=None, *args):
"""Log an event from a source.
Args:
source: str, like "MSU" or "user"
event: str, like "exit"
msg: str, optional, additional log output
args: list, optional, arguments supplied to msg as format args
"""
if not MSULOGENABLED:
return
if msg:
if args:
logging.info('@@%s:%s@@ ' + msg, source, event, *args)
else:
logging.info('@@%s:%s@@ %s', source, event, msg)
else:
logging.info('@@%s:%s@@', source, event)