Files
munki/code/client/munkilib/appleupdates.py

1861 lines
78 KiB
Python
Executable File

#!/usr/bin/python
# encoding: utf-8
"""
appleupdates.py
Utilities for dealing with Apple Software Update.
"""
# Copyright 2009-2016 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
#
# https://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.
import glob
import gzip
import hashlib
import os
import re
import subprocess
import time
import urllib2
import urlparse
from xml.dom import minidom
from xml.parsers import expat
import FoundationPlist
import fetch
import launchd
import munkicommon
import munkistatus
import updatecheck
# PyLint cannot properly find names inside Cocoa libraries, so issues bogus
# No name 'Foo' in module 'Bar' warnings. Disable them.
# pylint: disable=E0611
from AppKit import NSAttributedString
from Foundation import NSDate
from Foundation import NSBundle
from CoreFoundation import CFPreferencesAppValueIsForced
from CoreFoundation import CFPreferencesCopyAppValue
from CoreFoundation import CFPreferencesCopyKeyList
from CoreFoundation import CFPreferencesCopyValue
from CoreFoundation import CFPreferencesSetValue
from CoreFoundation import CFPreferencesSynchronize
from CoreFoundation import kCFPreferencesAnyUser
#from CoreFoundation import kCFPreferencesCurrentUser
from CoreFoundation import kCFPreferencesCurrentHost
from LaunchServices import LSFindApplicationForInfo
# pylint: enable=E0611
# Disable PyLint complaining about 'invalid' camelCase names
# pylint: disable=C0103
# Apple Software Update Catalog URLs.
DEFAULT_CATALOG_URLS = {
'10.6': ('http://swscan.apple.com/content/catalogs/others/'
'index-leopard-snowleopard.merged-1.sucatalog'),
'10.7': ('http://swscan.apple.com/content/catalogs/others/'
'index-lion-snowleopard-leopard.merged-1.sucatalog'),
'10.8': ('http://swscan.apple.com/content/catalogs/others/'
'index-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog'),
'10.9': ('https://swscan.apple.com/content/catalogs/others/'
'index-10.9-mountainlion-lion-snowleopard-leopard.merged-1'
'.sucatalog'),
'10.10': ('https://swscan.apple.com/content/catalogs/others/'
'index-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1'
'.sucatalog'),
'10.11': ('https://swscan.apple.com/content/catalogs/others/'
'index-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard'
'.merged-1.sucatalog'),
'10.12': ('https://swscan.apple.com/content/catalogs/others/'
'index-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard'
'-leopard.merged-1.sucatalog')
}
# Preference domain for Apple Software Update.
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN = 'com.apple.SoftwareUpdate'
# Apple's index of downloaded updates
INDEX_PLIST = '/Library/Updates/index.plist'
# Path to the directory where local catalogs are stored, relative to
# munkicommon.pref('ManagedInstallDir') + /swupd/mirror/.
LOCAL_CATALOG_DIR_REL_PATH = 'content/catalogs/'
# The pristine, untouched, but potentially gzipped catalog.
APPLE_DOWNLOAD_CATALOG_NAME = 'apple.sucatalog'
# The pristine, untouched, and extracted catalog.
APPLE_EXTRACTED_CATALOG_NAME = 'apple_index.sucatalog'
# The catalog containing only applicable updates
# This is used to replicate a subset of the software update
# server data to our local cache.
FILTERED_CATALOG_NAME = 'filtered_index.sucatalog'
# The catalog containing only updates to be downloaded and installed.
# We use this one when downloading Apple updates.
# In this case package URLs are still pointing to the
# software update server so we can download them, but the rest of the
# URLs point to our local cache.
LOCAL_DOWNLOAD_CATALOG_NAME = 'local_download.sucatalog'
# Catalog with all URLs (including package URLs) pointed to local cache.
# We use this one during install phase.
# This causes softwareupdate -i -a to fail cleanly if we don't
# have the required packages already downloaded.
LOCAL_CATALOG_NAME = 'local_install.sucatalog'
class Error(Exception):
"""Class for domain specific exceptions."""
class ReplicationError(Error):
"""A custom error when replication fails."""
class CatalogNotFoundError(Error):
"""A catalog was not found."""
class AppleUpdates(object):
"""Class to installation of Apple Software Updates within Munki.
This class handles update detection, as well as downloading and installation
of those updates.
"""
RESTART_ACTIONS = ['RequireRestart', 'RecommendRestart']
ORIGINAL_CATALOG_URL_KEY = '_OriginalCatalogURL'
def __init__(self):
self._managed_install_dir = munkicommon.pref('ManagedInstallDir')
# fix things if somehow we died last time before resetting the
# original CatalogURL
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if os_version_tuple in [(10, 9), (10, 10)]:
self._ResetOriginalCatalogURL()
real_cache_dir = os.path.join(self._managed_install_dir, 'swupd')
if os.path.exists(real_cache_dir):
if not os.path.isdir(real_cache_dir):
munkicommon.display_error(
'%s exists but is not a dir.', real_cache_dir)
else:
os.mkdir(real_cache_dir)
# symlink to work around an issue with paths containing spaces
# in 10.8.2's SoftwareUpdate
self.cache_dir = os.path.join('/tmp', 'munki_swupd_cache')
try:
if os.path.islink(self.cache_dir):
# remove any pre-existing symlink
os.unlink(self.cache_dir)
if os.path.exists(self.cache_dir):
# there should not be a file or directory at that path!
# move it
new_name = os.path.join(
'/tmp', ('munki_swupd_cache_moved_%s'
% time.strftime('%Y.%m.%d.%H.%M.%S')))
os.rename(self.cache_dir, new_name)
os.symlink(real_cache_dir, self.cache_dir)
except (OSError, IOError) as err:
# error in setting up the cache directories
raise Error('Could not configure cache directory: %s' % err)
self.temp_cache_dir = os.path.join(self.cache_dir, 'mirror')
self.local_catalog_dir = os.path.join(
self.cache_dir, LOCAL_CATALOG_DIR_REL_PATH)
self.apple_updates_plist = os.path.join(
self._managed_install_dir, 'AppleUpdates.plist')
self.apple_download_catalog_path = os.path.join(
self.temp_cache_dir, APPLE_DOWNLOAD_CATALOG_NAME)
self.filtered_catalog_path = os.path.join(
self.local_catalog_dir, FILTERED_CATALOG_NAME)
self.local_catalog_path = os.path.join(
self.local_catalog_dir, LOCAL_CATALOG_NAME)
self.extracted_catalog_path = os.path.join(
self.local_catalog_dir, APPLE_EXTRACTED_CATALOG_NAME)
self.local_download_catalog_path = os.path.join(
self.local_catalog_dir, LOCAL_DOWNLOAD_CATALOG_NAME)
self._update_list_cache = None
# apple_update_metadata support
self.client_id = ''
self.force_catalog_refresh = False
def getRestartAction(self, restart_action_list):
"""Returns the highest-weighted restart action of those in the list"""
# pylint: disable=no-self-use
restart_actions = [
'None', 'RequireLogout', 'RecommendRestart', 'RequireRestart']
highest_action_index = 0
for action in restart_action_list:
try:
highest_action_index = max(
restart_actions.index(action), highest_action_index)
except ValueError:
# action wasn't in our list
pass
return restart_actions[highest_action_index]
def GetFirmwareAlertText(self, dom):
'''If the update is a firmware update, returns some alert
text to display to the user, otherwise returns an empty
string. If we cannot read a custom firmware readme to use as
the alert, return "_DEFAULT_FIRMWARE_ALERT_TEXT_" '''
# pylint: disable=no-self-use
type_is_firmware = False
options = dom.getElementsByTagName('options')
for option in options:
if 'type' in option.attributes.keys():
type_value = option.attributes['type'].value
if type_value == 'firmware':
type_is_firmware = True
break
if type_is_firmware:
firmware_alert_text = '_DEFAULT_FIRMWARE_ALERT_TEXT_'
readmes = dom.getElementsByTagName('readme')
if len(readmes):
html = readmes[0].firstChild.data
html_data = buffer(html.encode('utf-8'))
attributed_string, _ = NSAttributedString.alloc(
).initWithHTML_documentAttributes_(html_data, None)
firmware_alert_text = attributed_string.string()
return firmware_alert_text
return ''
def parse_cdata(self, cdata_str):
'''Parses the CDATA string from an Apple Software Update distribution
file and returns a dictionary with key/value pairs.
The data in the CDATA string is in the format of an OS X .strings file,
which is generally:
"KEY1" = "VALUE1";
"KEY2"='VALUE2';
"KEY3" = 'A value
that spans
multiple lines.
';
Values can span multiple lines; either single or double-quotes can be
used to quote the keys and values, and the alternative quote character
is allowed as a literal inside the other, otherwise the quote character
is escaped.
//-style comments and blank lines are allowed in the string; these
should be skipped by the parser unless within a value.
'''
# pylint: disable=no-self-use
parsed_data = {}
REGEX = (r"""^\s*"""
r"""(?P<key_quote>['"]?)(?P<key>[^'"]+)(?P=key_quote)"""
r"""\s*=\s*"""
r"""(?P<value_quote>['"])(?P<value>.*?)(?P=value_quote);$""")
regex = re.compile(REGEX, re.MULTILINE | re.DOTALL)
# iterate through the string, finding all possible non-overlapping
# matches
for match_obj in re.finditer(regex, cdata_str):
match_dict = match_obj.groupdict()
if 'key' in match_dict.keys() and 'value' in match_dict.keys():
key = match_dict['key']
value = match_dict['value']
# now 'de-escape' escaped quotes
quote = match_dict.get('value_quote')
if quote:
escaped_quote = '\\' + quote
value = value.replace(escaped_quote, quote)
parsed_data[key] = value
return parsed_data
def parseSUdist(self, filename):
'''Parses a softwareupdate dist file, looking for information of
interest. Returns a dictionary containing the info we discovered in a
Munki-friendly format.'''
try:
dom = minidom.parse(filename)
except expat.ExpatError:
munkicommon.display_error(
'Invalid XML in %s', filename)
return None
except IOError, err:
munkicommon.display_error(
'Error reading %s: %s', filename, err)
return None
su_choice_id_key = 'su'
# look for <choices-outline ui='SoftwareUpdate'
choice_outlines = dom.getElementsByTagName('choices-outline') or []
for outline in choice_outlines:
if 'ui' in outline.attributes.keys():
if outline.attributes['ui'].value == 'SoftwareUpdate':
lines = outline.getElementsByTagName('line')
if lines:
if 'choice' in lines[0].attributes.keys():
su_choice_id_key = (
lines[0].attributes['choice'].value)
# get values from choice id=su_choice_id_key
# (there may be more than one!)
pkgs = {}
su_choice = {}
choice_elements = dom.getElementsByTagName('choice') or []
for choice in choice_elements:
keys = choice.attributes.keys()
if 'id' in keys:
choice_id = choice.attributes['id'].value
if choice_id == su_choice_id_key:
# this is the one Software Update uses
for key in keys:
su_choice[key] = choice.attributes[key].value
pkg_refs = choice.getElementsByTagName('pkg-ref') or []
for pkg in pkg_refs:
if 'id' in pkg.attributes.keys():
pkg_id = pkg.attributes['id'].value
if not pkg_id in pkgs.keys():
pkgs[pkg_id] = {}
# now get all pkg-refs so we can assemble all metadata
# there is additional metadata in pkg-refs outside of the
# choices element
pkg_refs = dom.getElementsByTagName('pkg-ref') or []
for pkg in pkg_refs:
if 'id' in pkg.attributes.keys():
pkg_id = pkg.attributes['id'].value
if not pkg_id in pkgs.keys():
# this pkg_id was not in our choice list
continue
if pkg.firstChild:
try:
pkg_name = pkg.firstChild.wholeText
if pkg_name:
pkgs[pkg_id]['name'] = pkg_name
except AttributeError:
pass
if 'onConclusion' in pkg.attributes.keys():
pkgs[pkg_id]['RestartAction'] = (
pkg.attributes['onConclusion'].value)
if 'version' in pkg.attributes.keys():
pkgs[pkg_id]['version'] = (
pkg.attributes['version'].value)
if 'installKBytes' in pkg.attributes.keys():
pkgs[pkg_id]['installed_size'] = int(
pkg.attributes['installKBytes'].value)
if 'packageIdentifier' in pkg.attributes.keys():
pkgs[pkg_id]['packageid'] = (
pkg.attributes['packageIdentifier'].value)
# look for localization and parse CDATA
cdata = {}
localizations = dom.getElementsByTagName('localization')
if localizations:
string_elements = localizations[0].getElementsByTagName('strings')
if string_elements:
strings = string_elements[0]
if strings.firstChild:
try:
text = strings.firstChild.wholeText
cdata = self.parse_cdata(text)
except AttributeError:
cdata = {}
# get blocking_applications, if any.
# First, find all the must-close items.
must_close_app_ids = []
must_close_items = dom.getElementsByTagName('must-close')
for item in must_close_items:
apps = item.getElementsByTagName('app')
for app in apps:
keys = app.attributes.keys()
if 'id' in keys:
must_close_app_ids.append(app.attributes['id'].value)
# next, we convert Apple's must-close items to
# Munki's blocking_applications
blocking_apps = []
# this will only find blocking_applications that are currently installed
# on the machine running this code, but that's OK for our needs
#
# use set() to eliminate any duplicate application ids
for app_id in set(must_close_app_ids):
dummy_resultcode, dummy_fileref, nsurl = LSFindApplicationForInfo(
0, app_id, None, None, None)
if nsurl and nsurl.isFileURL():
pathname = nsurl.path()
dirname = os.path.dirname(pathname)
executable = munkicommon.getAppBundleExecutable(pathname)
if executable:
# path to executable should be location agnostic
executable = executable[len(dirname + '/'):]
blocking_apps.append(executable or pathname)
# get firmware alert text if any
firmware_alert_text = self.GetFirmwareAlertText(dom)
# assemble!
info = {}
info['name'] = su_choice.get('suDisabledGroupID', '')
info['display_name'] = su_choice.get('title', '')
info['apple_product_name'] = info['display_name']
info['version_to_install'] = su_choice.get('versStr', '')
info['description'] = su_choice.get('description', '')
for key in info.keys():
if info[key].startswith('SU_'):
# get value from cdata dictionary
info[key] = cdata.get(info[key], info[key])
#info['pkg_refs'] = pkgs
installed_size = 0
for pkg in pkgs.values():
installed_size += pkg.get('installed_size', 0)
info['installed_size'] = installed_size
if blocking_apps:
info['blocking_applications'] = blocking_apps
restart_actions = [pkg['RestartAction']
for pkg in pkgs.values() if 'RestartAction' in pkg]
effective_restart_action = self.getRestartAction(restart_actions)
if effective_restart_action != 'None':
info['RestartAction'] = effective_restart_action
if firmware_alert_text:
info['firmware_alert_text'] = firmware_alert_text
return info
def _ResetMunkiStatusAndDisplayMessage(self, message):
"""Resets MunkiStatus detail/percent, logs and msgs GUI.
Args:
message: str message to display to the user and log.
"""
# pylint: disable=no-self-use
munkicommon.display_status_major(message)
def _GetURLPath(self, full_url):
"""Returns only the URL path.
Args:
full_url: a str URL, complete with schema, domain, path, etc.
Returns:
The str path of the URL.
"""
# pylint: disable=no-self-use
return urlparse.urlsplit(full_url)[2] # (schema, netloc, path, ...)
def RewriteURL(self, full_url):
"""Rewrites a single URL to point to our local replica.
Args:
full_url: a str URL, complete with schema, domain, path, etc.
Returns:
A str URL, rewritten if needed to point to the local cache.
"""
local_base_url = 'file://localhost' + urllib2.quote(self.cache_dir)
if full_url.startswith(local_base_url):
return full_url # url is already local, so just return it.
return local_base_url + self._GetURLPath(full_url)
def RewriteProductURLs(self, product, rewrite_pkg_urls=False):
"""Rewrites URLs in the product to point to our local cache.
Args:
product: list, of dicts, product info. This dict is changed by
this function.
rewrite_pkg_urls: bool, default False, if True package URLs are
rewritten, otherwise only MetadataURLs are rewritten.
"""
if 'ServerMetadataURL' in product:
product['ServerMetadataURL'] = self.RewriteURL(
product['ServerMetadataURL'])
for package in product.get('Packages', []):
if rewrite_pkg_urls and 'URL' in package:
package['URL'] = self.RewriteURL(package['URL'])
if 'MetadataURL' in package:
package['MetadataURL'] = self.RewriteURL(
package['MetadataURL'])
distributions = product['Distributions']
for dist_lang in distributions.keys():
distributions[dist_lang] = self.RewriteURL(
distributions[dist_lang])
def RewriteCatalogURLs(self, catalog, rewrite_pkg_urls=False):
"""Rewrites URLs in a catalog to point to our local replica.
Args:
rewrite_pkg_urls: Boolean, if True package URLs are rewritten,
otherwise only MetadataURLs are rewritten.
"""
if not 'Products' in catalog:
return
for product_key in catalog['Products'].keys():
product = catalog['Products'][product_key]
self.RewriteProductURLs(product, rewrite_pkg_urls=rewrite_pkg_urls)
def RetrieveURLToCacheDir(self, full_url, copy_only_if_missing=False):
"""Downloads a URL and stores it in the same relative path on our
filesystem. Returns a path to the replicated file.
Args:
full_url: str, full URL to retrieve.
copy_only_if_missing: boolean, True to copy only if the file is not
already cached, False to copy regardless of existence in cache.
Returns:
String path to the locally cached file.
"""
relative_url = os.path.normpath(self._GetURLPath(full_url).lstrip('/'))
local_file_path = os.path.join(self.cache_dir, relative_url)
local_dir_path = os.path.dirname(local_file_path)
if copy_only_if_missing and os.path.exists(local_file_path):
return local_file_path
if not os.path.exists(local_dir_path):
try:
os.makedirs(local_dir_path)
except OSError as oserr:
raise ReplicationError(oserr)
try:
self.GetSoftwareUpdateResource(
full_url, local_file_path, resume=True)
except fetch.MunkiDownloadError as err:
raise ReplicationError(err)
return local_file_path
def GetSoftwareUpdateResource(self, url, destinationpath, resume=False):
"""Gets item from Apple Software Update Server.
Args:
url: str, URL of the resource to download.
destinationpath: str, path of the destination to save the resource.
resume: boolean, True to resume downloads, False to redownload.
Returns:
Boolean. True if a new download was required, False if the item was
already in the local cache.
"""
# pylint: disable=no-self-use
machine = munkicommon.getMachineFacts()
darwin_version = os.uname()[2]
# Set the User-Agent header to match that used by Apple's
# softwareupdate client for better compatibility.
user_agent_header = (
"User-Agent: managedsoftwareupdate/%s Darwin/%s (%s) (%s)"
% (machine['munki_version'], darwin_version,
machine['arch'], machine['machine_model']))
return fetch.getResourceIfChangedAtomically(
url, destinationpath, custom_headers=[user_agent_header],
resume=resume, follow_redirects=True)
def CacheUpdateMetadata(self):
"""Copies ServerMetadata (.smd), Metadata (.pkm), and
Distribution (.dist) files for the available updates to the local
machine and writes a new sucatalog that refers to the local copies
of these files."""
catalog = FoundationPlist.readPlist(self.filtered_catalog_path)
if not 'Products' in catalog:
munkicommon.display_warning(
'"Products" not found in %s', self.filtered_catalog_path)
return
for product_key in catalog['Products'].keys():
if munkicommon.stopRequested():
break
munkicommon.display_status_minor(
'Caching metadata for product ID %s', product_key)
product = catalog['Products'][product_key]
if 'ServerMetadataURL' in product:
self.RetrieveURLToCacheDir(
product['ServerMetadataURL'], copy_only_if_missing=True)
for package in product.get('Packages', []):
if munkicommon.stopRequested():
break
if 'MetadataURL' in package:
munkicommon.display_status_minor(
'Caching package metadata for product ID %s',
product_key)
self.RetrieveURLToCacheDir(
package['MetadataURL'], copy_only_if_missing=True)
# if 'URL' in package:
# munkicommon.display_status_minor(
# 'Caching package for product ID %s',
# product_key)
# self.RetrieveURLToCacheDir(
# package['URL'], copy_only_if_missing=True)
distributions = product['Distributions']
for dist_lang in distributions.keys():
if munkicommon.stopRequested():
break
munkicommon.display_status_minor(
'Caching %s distribution for product ID %s',
dist_lang, product_key)
dist_url = distributions[dist_lang]
try:
self.RetrieveURLToCacheDir(
dist_url, copy_only_if_missing=True)
except ReplicationError:
munkicommon.display_warning(
'Could not cache %s distribution for product ID %s',
dist_lang, product_key)
if munkicommon.stopRequested():
return
if not os.path.exists(self.local_catalog_dir):
try:
os.makedirs(self.local_catalog_dir)
except OSError as oserr:
raise ReplicationError(oserr)
# rewrite metadata URLs to point to local caches.
self.RewriteCatalogURLs(catalog, rewrite_pkg_urls=False)
FoundationPlist.writePlist(
catalog, self.local_download_catalog_path)
# rewrite all URLs, including pkgs, to point to local caches.
self.RewriteCatalogURLs(catalog, rewrite_pkg_urls=True)
FoundationPlist.writePlist(catalog, self.local_catalog_path)
def _GetPreferredLocalization(self, list_of_localizations):
'''Picks the best localization from a list of available
localizations. Returns a single language/localization name.'''
# pylint: disable=no-self-use
localization_preferences = (
munkicommon.pref('AppleSoftwareUpdateLanguages') or ['English'])
preferred_langs = (
NSBundle.preferredLocalizationsFromArray_forPreferences_(
list_of_localizations, localization_preferences))
if preferred_langs:
return preferred_langs[0]
# first fallback, return en or English
if 'English' in list_of_localizations:
return 'English'
elif 'en' in list_of_localizations:
return 'en'
# if we get this far, just return the first language
# in the list of available languages
return list_of_localizations[0]
def GetDistributionForProductKey(
self, product_key, sucatalog, language=None):
'''Returns the path to a distibution file from /Library/Updates
or the local cache for the given product_key. If language is
defined it will try to retrieve that specific language, otherwise
it will use the available languages and the value of the
AppleSoftwareUpdateLanguages preference to return the "best"
language of those available.'''
try:
catalog = FoundationPlist.readPlist(sucatalog)
except FoundationPlist.NSPropertyListSerializationException:
return None
product = catalog.get('Products', {}).get(product_key, {})
if product:
distributions = product.get('Distributions', {})
if distributions:
available_languages = distributions.keys()
if language:
preferred_language = language
else:
preferred_language = self._GetPreferredLocalization(
available_languages)
url = distributions[preferred_language]
# do we already have it in /Library/Updates?
filename = os.path.basename(self._GetURLPath(url))
dist_path = os.path.join(
'/Library/Updates', product_key, filename)
if os.path.exists(dist_path):
return dist_path
# look for it in the cache
if url.startswith('file://localhost'):
fileurl = url[len('file://localhost'):]
dist_path = urllib2.unquote(fileurl)
if os.path.exists(dist_path):
return dist_path
# we haven't downloaded this yet
try:
return self.RetrieveURLToCacheDir(
url, copy_only_if_missing=True)
except ReplicationError, err:
munkicommon.display_error(
'Could not retrieve %s: %s', url, err)
return None
def _WriteFilteredCatalog(self, product_ids, catalog_path):
"""Write out a sucatalog containing only the updates in product_ids.
Args:
product_ids: list of str, ProductIDs.
catalog_path: str, path of catalog to write.
"""
catalog = FoundationPlist.readPlist(self.extracted_catalog_path)
product_ids = set(product_ids) # convert to set for O(1) lookups.
for product_id in list(catalog.get('Products', [])):
if product_id not in product_ids:
del catalog['Products'][product_id]
FoundationPlist.writePlist(catalog, catalog_path)
def IsRestartNeeded(self):
"""Returns True if any update requires an restart."""
try:
apple_updates = FoundationPlist.readPlist(self.apple_updates_plist)
except FoundationPlist.NSPropertyListSerializationException:
return True
for item in apple_updates.get('AppleUpdates', []):
if item.get('RestartAction') in self.RESTART_ACTIONS:
return True
# if we get this far, there must be no items that require restart
return False
def ClearAppleUpdateInfo(self):
"""Clears Apple update info.
This is called after performing munki updates because the Apple updates
may no longer be relevant.
"""
try:
os.unlink(self.apple_updates_plist)
except (OSError, IOError):
pass
def DownloadAvailableUpdates(self):
"""Downloads available Apple updates.
Returns:
Boolean. True if successful, False otherwise.
"""
#msg = 'Downloading available Apple Software Updates...'
msg = 'Checking for available Apple Software Updates...'
self._ResetMunkiStatusAndDisplayMessage(msg)
if os.path.exists(INDEX_PLIST):
# try to remove old/stale /Library/Updates/index.plist
# in some older versions of OS X this can hang around
# and is not always cleaned up when /usr/sbin/softwareupdate
# finds no updates
try:
os.unlink(INDEX_PLIST)
except OSError:
pass
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if os_version_tuple >= (10, 11):
catalog_url = None
else:
catalog_url = self._GetAppleCatalogURL()
retcode = self._RunSoftwareUpdate(
['-d', '-a'], catalog_url=catalog_url, stop_allowed=True)
if retcode: # there was an error
munkicommon.display_error('softwareupdate error: %s', retcode)
return False
# not sure all older OS X versions set LastSessionSuccessful, so
# react only if it's explicitly set to False
last_session_successful = self.GetSoftwareUpdatePref(
'LastSessionSuccessful')
if last_session_successful is False:
munkicommon.display_error(
'softwareupdate reported an unsuccessful download session.')
return False
return True
def GetAvailableUpdateProductIDs(self):
"""Returns a list of product IDs of available Apple updates.
Returns:
A list of string Apple update products ids.
"""
# pylint: disable=no-self-use
# first, try to get the list from com.apple.SoftwareUpdate preferences
recommended_updates = self.GetSoftwareUpdatePref(
'RecommendedUpdates')
if recommended_updates:
return [item['Product Key'] for item in recommended_updates
if 'Product Key' in item]
# not in com.apple.SoftwareUpdate preferences, try index.plist
if not os.path.exists(INDEX_PLIST):
munkicommon.display_debug1('%s does not exist.' % INDEX_PLIST)
return []
try:
product_index = FoundationPlist.readPlist(INDEX_PLIST)
products = product_index.get('ProductPaths', {})
return products.keys()
except (FoundationPlist.FoundationPlistException,
KeyError, AttributeError), err:
munkicommon.display_error(
"Error processing %s: %s", INDEX_PLIST, err)
return []
def ExtractAndCopyDownloadedCatalog(self, _open=open):
"""Copy the downloaded catalog to a new file, extracting if gzipped.
Args:
_open: func, default builtin open(), open method for unit testing.
"""
if not os.path.exists(self.local_catalog_dir):
try:
os.makedirs(self.local_catalog_dir)
except OSError as oserr:
raise ReplicationError(oserr)
local_apple_sus_catalog = os.path.join(
self.local_catalog_dir, APPLE_EXTRACTED_CATALOG_NAME)
f = _open(self.apple_download_catalog_path, 'rb')
magic = f.read(2)
contents = ''
if magic == '\x1f\x8b': # File is gzip compressed.
f.close() # Close the open handle first.
f = gzip.open(self.apple_download_catalog_path, 'rb')
else: # Hopefully a nice plain plist.
f.seek(0)
contents = f.read()
f.close()
f = _open(local_apple_sus_catalog, 'wb')
f.write(contents)
f.close()
def _GetAppleCatalogURL(self):
"""Returns the catalog URL of the Apple SU catalog for the current Mac.
Returns:
String catalog URL for the current Mac.
Raises:
CatalogNotFoundError: an Apple catalog was not found for this Mac.
"""
# Prefer Munki's preferences file.
munkisuscatalog = munkicommon.pref('SoftwareUpdateServerURL')
if munkisuscatalog:
return munkisuscatalog
# Otherwise prefer MCX or /Library/Preferences/com.apple.SoftwareUpdate
prefs_catalog_url = self.GetSoftwareUpdatePref('CatalogURL')
if prefs_catalog_url:
return prefs_catalog_url
# Finally, fall back to using a hard-coded url in DEFAULT_CATALOG_URLS.
os_version = munkicommon.getOsVersion()
catalog_url = DEFAULT_CATALOG_URLS.get(os_version, None)
if catalog_url:
return catalog_url
raise CatalogNotFoundError(
'No default Software Update CatalogURL for: %s' % os_version)
def CacheAppleCatalog(self):
"""Caches a local copy of the current Apple SUS catalog.
Raises:
CatalogNotFoundError: a catalog was not found to cache.
ReplicationError: there was an error making the cache directory.
fetch.MunkiDownloadError: error downloading the catalog.
"""
try:
catalog_url = self._GetAppleCatalogURL()
except CatalogNotFoundError as err:
munkicommon.display_error(unicode(err))
raise
if not os.path.exists(self.temp_cache_dir):
try:
os.makedirs(self.temp_cache_dir)
except OSError as oserr:
raise ReplicationError(oserr)
msg = 'Checking Apple Software Update catalog...'
self._ResetMunkiStatusAndDisplayMessage(msg)
munkicommon.display_detail('Caching CatalogURL %s', catalog_url)
try:
dummy_file_changed = self.GetSoftwareUpdateResource(
catalog_url, self.apple_download_catalog_path, resume=True)
self.ExtractAndCopyDownloadedCatalog()
except fetch.MunkiDownloadError:
raise
def InstalledApplePackagesHaveChanged(self):
"""Generates a SHA-256 checksum of the info for all packages in the
receipts database whose id matches com.apple.* and compares it to a
stored version of this checksum.
Returns:
Boolean. False if the checksums match, True if they differ."""
# pylint: disable=no-self-use
cmd = ['/usr/sbin/pkgutil', '--regexp', '--pkg-info-plist',
r'com\.apple\.*']
proc = subprocess.Popen(cmd, shell=False, bufsize=1,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, dummy_err = proc.communicate()
current_apple_packages_checksum = hashlib.sha256(output).hexdigest()
old_apple_packages_checksum = munkicommon.pref(
'InstalledApplePackagesChecksum')
if current_apple_packages_checksum == old_apple_packages_checksum:
return False
else:
munkicommon.set_pref('InstalledApplePackagesChecksum',
current_apple_packages_checksum)
return True
def _IsForceCheckNeccessary(self, original_hash):
"""Returns True if a force check is needed, False otherwise.
Args:
original_hash: the SHA-256 hash of the Apple catalog before being
redownloaded.
Returns:
Boolean. True if a force check is needed, False otherwise.
"""
new_hash = munkicommon.getsha256hash(self.apple_download_catalog_path)
if original_hash != new_hash:
munkicommon.log('Apple update catalog has changed.')
return True
if self.InstalledApplePackagesHaveChanged():
munkicommon.log('Installed Apple packages have changed.')
return True
if not self.AvailableUpdatesAreDownloaded():
munkicommon.log('Downloaded updates do not match our list '
'of available updates.')
return True
return False
def CheckForSoftwareUpdates(self, force_check=True):
"""Check if Apple Software Updates are available, if needed or forced.
Args:
force_check: Boolean. If True, forces a check, otherwise only checks
if the last check is deemed outdated.
Returns:
Boolean. True if there are updates, False otherwise.
"""
before_hash = munkicommon.getsha256hash(
self.apple_download_catalog_path)
try:
self.CacheAppleCatalog()
except CatalogNotFoundError:
return False
except (ReplicationError, fetch.MunkiDownloadError) as err:
munkicommon.display_warning(
'Could not download Apple SUS catalog:')
munkicommon.display_warning('\t%s', unicode(err))
return False
if not force_check and not self._IsForceCheckNeccessary(before_hash):
munkicommon.display_info(
'Skipping Apple Software Update check '
'because sucatalog is unchanged, installed Apple packages are '
'unchanged and we recently did a full check.')
# return True if we have cached updates
# False otherwise
if self.GetSoftwareUpdateInfo():
return True
else:
return False
if self.DownloadAvailableUpdates(): # Success; ready to install.
munkicommon.set_pref('LastAppleSoftwareUpdateCheck', NSDate.date())
product_ids = self.GetAvailableUpdateProductIDs()
if not product_ids:
# No updates found
return False
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if os_version_tuple < (10, 11):
self._WriteFilteredCatalog(
product_ids, self.filtered_catalog_path)
try:
self.CacheUpdateMetadata()
except ReplicationError as err:
munkicommon.display_warning(
'Could not replicate software update metadata:')
munkicommon.display_warning('\t%s', unicode(err))
return False
return True
else:
# Download error, allow check again soon.
munkicommon.display_error(
'Could not download all available Apple updates.')
munkicommon.set_pref('LastAppleSoftwareUpdateCheck', None)
return False
def UpdateDownloaded(self, product_key):
"""Verifies that a given update appears to be downloaded.
Returns a boolean."""
# pylint: disable=no-self-use
product_dir = os.path.join('/Library/Updates', product_key)
if not os.path.isdir(product_dir):
munkicommon.log(
'Apple Update product directory %s is missing'
% product_key)
return False
else:
pkgs = glob.glob(os.path.join(product_dir, '*.pkg'))
if not pkgs:
munkicommon.log(
'Apple Update product directory %s contains no pkgs'
% product_key)
return False
return True
def AvailableUpdatesAreDownloaded(self):
"""Verifies that applicable/available updates have been downloaded.
Returns:
Boolean. False if a product directory are missing,
True otherwise (including when there are no available
updates).
"""
apple_updates = self.GetSoftwareUpdateInfo()
if not apple_updates:
return True
for update in apple_updates:
if not self.UpdateDownloaded(update.get('productKey')):
return False
return True
def GetSoftwareUpdateInfo(self):
"""Uses /Library/Preferences/com.apple.SoftwareUpdate.plist or
/Library/Updates/index.plist to generate the AppleUpdates.plist,
which records available updates in the format that
Managed Software Update.app expects.
Returns:
List of dictionary update data.
"""
update_display_names = {}
update_versions = {}
product_keys = []
english_su_info = {}
apple_updates = []
# first, try to get the list from com.apple.SoftwareUpdate preferences
recommended_updates = self.GetSoftwareUpdatePref(
'RecommendedUpdates')
if recommended_updates:
for item in recommended_updates:
try:
update_display_names[item['Product Key']] = (
item['Display Name'])
except (TypeError, AttributeError, KeyError):
pass
try:
update_versions[item['Product Key']] = (
item['Display Version'])
except (TypeError, AttributeError, KeyError):
pass
try:
product_keys = [item['Product Key']
for item in recommended_updates]
except (TypeError, AttributeError, KeyError):
pass
if not product_keys:
# next, try to get the applicable/recommended updates from
# /Library/Updates/index.plist
if os.path.exists(INDEX_PLIST):
try:
product_index = FoundationPlist.readPlist(INDEX_PLIST)
products = product_index.get('ProductPaths', {})
product_keys = products.keys()
except (FoundationPlist.FoundationPlistException,
AttributeError, TypeError), err:
munkicommon.display_error(
"Error parsing %s: %s", INDEX_PLIST, err)
if product_keys:
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if os_version_tuple < (10, 11):
sucatalog = self.local_catalog_path
else:
# use the cached Apple catalog
sucatalog = self.extracted_catalog_path
for product_key in product_keys:
if not self.UpdateDownloaded(product_key):
munkicommon.display_warning(
'Product %s does not appear to be downloaded',
product_key)
continue
localized_dist = self.GetDistributionForProductKey(
product_key, sucatalog)
if not localized_dist:
munkicommon.display_warning(
'No dist file for product %s', product_key)
continue
if (not recommended_updates and
not localized_dist.endswith('English.dist')):
# we need the English versions of some of the data
# see (https://groups.google.com/d/msg/munki-dev/
# _5HdMyy3kKU/YFxqslayDQAJ)
english_dist = self.GetDistributionForProductKey(
product_key, sucatalog, 'English')
if english_dist:
english_su_info = self.parseSUdist(english_dist)
su_info = self.parseSUdist(localized_dist)
su_info['productKey'] = product_key
if su_info['name'] == '':
su_info['name'] = product_key
if product_key in update_display_names:
su_info['apple_product_name'] = (
update_display_names[product_key])
elif english_su_info:
su_info['apple_product_name'] = (
english_su_info['apple_product_name'])
if product_key in update_versions:
su_info['version_to_install'] = (
update_versions[product_key])
elif english_su_info:
su_info['version_to_install'] = (
english_su_info['version_to_install'])
apple_updates.append(su_info)
return apple_updates
def WriteAppleUpdatesFile(self):
"""Writes a file used by the MSU GUI to display available updates.
Returns:
Integer. Count of available Apple updates.
"""
apple_updates = self.GetSoftwareUpdateInfo()
if apple_updates:
if not munkicommon.pref('AppleSoftwareUpdatesOnly'):
cataloglist = updatecheck.getPrimaryManifestCatalogs(
self.client_id, force_refresh=self.force_catalog_refresh)
if cataloglist:
# Check for apple_update_metadata
munkicommon.display_detail(
'**Checking for Apple Update Metadata**')
for item in apple_updates:
# Find matching metadata item
metadata_item = updatecheck.getItemDetail(
item['productKey'], cataloglist,
vers='apple_update_metadata')
if metadata_item:
munkicommon.display_debug1(
'Processing metadata for %s, %s...',
item['productKey'], item['display_name'])
self.copyUpdateMetadata(item, metadata_item)
plist = {'AppleUpdates': apple_updates}
FoundationPlist.writePlist(plist, self.apple_updates_plist)
return len(apple_updates)
else:
try:
os.unlink(self.apple_updates_plist)
except (OSError, IOError):
pass
return 0
def DisplayAppleUpdateInfo(self):
"""Prints Apple update information and updates ManagedInstallReport."""
try:
pl_dict = FoundationPlist.readPlist(self.apple_updates_plist)
except FoundationPlist.FoundationPlistException:
munkicommon.display_error(
'Error reading: %s', self.apple_updates_plist)
return
apple_updates = pl_dict.get('AppleUpdates', [])
if not apple_updates:
munkicommon.display_info('No available Apple Software Updates.')
return
munkicommon.report['AppleUpdates'] = apple_updates
munkicommon.display_info(
'The following Apple Software Updates are available to '
'install:')
for item in apple_updates:
munkicommon.display_info(
' + %s-%s' % (
item.get('display_name', ''),
item.get('version_to_install', '')))
if item.get('RestartAction') in self.RESTART_ACTIONS:
munkicommon.display_info(' *Restart required')
munkicommon.report['RestartRequired'] = True
elif item.get('RestartAction') == 'RequireLogout':
munkicommon.display_info(' *Logout required')
munkicommon.report['LogoutRequired'] = True
def GetSoftwareUpdatePref(self, pref_name):
"""Returns a preference from com.apple.SoftwareUpdate.
Uses CoreFoundation.
Args:
pref_name: str preference name to get.
"""
# pylint: disable=no-self-use
return CFPreferencesCopyAppValue(
pref_name, APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN)
def _GetCatalogURL(self):
"""Returns Software Update's CatalogURL"""
# pylint: disable=no-self-use
return CFPreferencesCopyValue(
'CatalogURL',
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
def _SetCustomCatalogURL(self, catalog_url):
"""Sets Software Update's CatalogURL to custom value, storing the
original"""
software_update_key_list = CFPreferencesCopyKeyList(
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost) or []
if self.ORIGINAL_CATALOG_URL_KEY not in software_update_key_list:
# store the original CatalogURL
original_catalog_url = self._GetCatalogURL()
if not original_catalog_url:
# can't store None as a CFPreference
original_catalog_url = ""
CFPreferencesSetValue(
self.ORIGINAL_CATALOG_URL_KEY,
original_catalog_url,
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
# now set our custom CatalogURL
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if os_version_tuple < (10, 11):
CFPreferencesSetValue(
'CatalogURL', catalog_url,
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
# finally, sync things up
if not CFPreferencesSynchronize(
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost):
munkicommon.display_error(
'Error setting com.apple.SoftwareUpdate CatalogURL.')
else:
# use softwareupdate --set-catalog
proc = subprocess.Popen(
['/usr/sbin/softwareupdate', '--set-catalog', catalog_url],
bufsize=-1, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(output, err) = proc.communicate()
if output:
munkicommon.display_detail(output)
if err:
munkicommon.display_error(err)
def _ResetOriginalCatalogURL(self):
"""Resets SoftwareUpdate's CatalogURL to the original value"""
software_update_key_list = CFPreferencesCopyKeyList(
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost) or []
if self.ORIGINAL_CATALOG_URL_KEY not in software_update_key_list:
# do nothing
return
original_catalog_url = CFPreferencesCopyValue(
self.ORIGINAL_CATALOG_URL_KEY,
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
if not original_catalog_url:
original_catalog_url = None
# reset CatalogURL to the one we stored
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if os_version_tuple < (10, 11):
CFPreferencesSetValue(
'CatalogURL', original_catalog_url,
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
else:
if original_catalog_url:
# use softwareupdate --set-catalog
cmd = ['/usr/sbin/softwareupdate',
'--set-catalog', original_catalog_url]
else:
# use softwareupdate --clear-catalog
cmd = ['/usr/sbin/softwareupdate', '--clear-catalog']
proc = subprocess.Popen(cmd, bufsize=-1, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(output, err) = proc.communicate()
if output:
munkicommon.display_detail(output)
if err:
munkicommon.display_error(err)
# remove ORIGINAL_CATALOG_URL_KEY
CFPreferencesSetValue(
self.ORIGINAL_CATALOG_URL_KEY, None,
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
# sync
if not CFPreferencesSynchronize(
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost):
munkicommon.display_error(
'Error resetting com.apple.SoftwareUpdate CatalogURL.')
def CatalogURLisManaged(self):
"""Returns True if Software Update's CatalogURL is managed
via MCX or Profiles"""
# pylint: disable=no-self-use
return CFPreferencesAppValueIsForced(
'CatalogURL', APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN)
def _RunSoftwareUpdate(
self, options_list, catalog_url=None, stop_allowed=False,
mode=None, results=None):
"""Runs /usr/sbin/softwareupdate with options.
Provides user feedback via command line or MunkiStatus.
Args:
options_list: sequence of options to send to softwareupdate.
stopped_allowed:
mode:
results:
Returns:
Integer softwareupdate exit code.
"""
if results is None:
# we're not interested in the results,
# but need to create a temporary dict anyway
results = {}
# we need to wrap our call to /usr/sbin/softwareupdate with a utility
# that makes softwareupdate think it is connected to a tty-like
# device so its output is unbuffered so we can get progress info
#
# Try to find our ptyexec tool
# first look in the parent directory of this file's directory
# (../)
parent_dir = os.path.dirname(
os.path.dirname(
os.path.abspath(__file__)))
ptyexec_path = os.path.join(parent_dir, 'ptyexec')
if not os.path.exists(ptyexec_path):
# try absolute path in munki's normal install dir
ptyexec_path = '/usr/local/munki/ptyexec'
if os.path.exists(ptyexec_path):
cmd = [ptyexec_path]
else:
# fall back to /usr/bin/script
# this is not preferred because it uses way too much CPU
# checking stdin for input that will never come...
cmd = ['/usr/bin/script', '-q', '-t', '1', '/dev/null']
cmd.extend(['/usr/sbin/softwareupdate', '--verbose'])
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if catalog_url:
# OS version-specific stuff to use a specific CatalogURL
if os_version_tuple < (10, 9):
cmd.extend(['--CatalogURL', catalog_url])
elif os_version_tuple in [(10, 9), (10, 10)]:
self._SetCustomCatalogURL(catalog_url)
cmd.extend(options_list)
munkicommon.display_debug1('softwareupdate cmd: %s', cmd)
try:
job = launchd.Job(cmd)
job.start()
except launchd.LaunchdJobException as err:
munkicommon.display_warning(
'Error with launchd job (%s): %s', cmd, err)
munkicommon.display_warning('Skipping softwareupdate run.')
return -3
results['installed'] = []
results['download'] = []
results['failures'] = []
last_output = None
while True:
if stop_allowed and munkicommon.stopRequested():
job.stop()
break
output = job.stdout.readline()
if not output:
if job.returncode() is not None:
break
else:
# no data, but we're still running
# sleep a bit before checking for more output
time.sleep(1)
continue
# Don't bother parsing the stdout output if it hasn't changed since
# the last loop iteration.
if last_output == output:
continue
last_output = output
output = output.decode('UTF-8').strip()
# send the output to STDOUT or MunkiStatus as applicable
if output.startswith('Progress: '):
# Snow Leopard/Lion progress info with '-v' flag
try:
percent = int(output[10:].rstrip('%'))
except ValueError:
percent = -1
munkicommon.display_percent_done(percent, 100)
elif output.startswith('Software Update Tool'):
# don't display this
pass
elif output.startswith('Copyright 2'):
# don't display this
pass
elif output.startswith('Installing ') and mode == 'install':
item = output[11:]
if item:
self._ResetMunkiStatusAndDisplayMessage(output)
elif output.startswith('Downloaded ') and mode == 'install':
# don't display this
pass
elif output.startswith('Installed '):
# 10.6 / 10.7 / 10.8. Successful install of package name.
if mode == 'install':
munkicommon.display_status_minor(output)
results['installed'].append(output[10:])
else:
pass
# don't display.
# softwareupdate logging "Installed" at the end of a
# successful download-only session is odd.
elif output.startswith('Done with ') and mode == 'install':
# 10.9 successful install
munkicommon.display_status_minor(output)
results['installed'].append(output[10:])
elif output.startswith('Done '):
# 10.5. Successful install of package name.
munkicommon.display_status_minor(output)
results['installed'].append(output[5:])
elif output.startswith('Downloading ') and mode == 'install':
# This is 10.5 & 10.7 behavior for a missing subpackage.
munkicommon.display_warning(
'A necessary subpackage is not available on disk '
'during an Apple Software Update installation '
'run: %s' % output)
results['download'].append(output[12:])
elif output.startswith('Package failed:'):
# Doesn't tell us which package.
munkicommon.display_error(
'Apple update failed to install: %s' % output)
results['failures'].append(output)
elif output.startswith('x '):
# don't display this, it's just confusing
pass
elif 'Missing bundle identifier' in output:
# don't display this, it's noise
pass
elif output == '':
pass
else:
munkicommon.display_status_minor(output)
if catalog_url:
# reset CatalogURL if needed
if os_version_tuple in [(10, 9), (10, 10)]:
self._ResetOriginalCatalogURL()
retcode = job.returncode()
if retcode == 0:
# get SoftwareUpdate's LastResultCode
last_result_code = self.GetSoftwareUpdatePref(
'LastResultCode') or 0
if last_result_code > 2:
retcode = last_result_code
if results['failures']:
return 1
return retcode
# TODO(jrand): The below functions are externally called. Should all
# others be private?
def InstallAppleUpdates(self, only_unattended=False):
"""Uses softwareupdate to install previously downloaded updates.
Returns:
Boolean. True if a restart is needed after install, False otherwise.
"""
# disable Stop button if we are presenting GUI status
if munkicommon.munkistatusoutput:
munkistatus.hideStopButton()
# Get list of unattended_installs
if only_unattended:
msg = 'Installing unattended Apple Software Updates...'
# Creating an 'unattended_install' filtered catalog
# against the existing filtered catalog is not an option as
# cached downloads are purged if they do not exist in the
# filtered catalog. Instead, get a list of updates, and their
# product_ids, that are eligible for unattended_install.
unattended_install_items, unattended_install_product_ids = \
self.GetUnattendedInstalls()
# ensure that we don't restart for unattended installations
restartneeded = False
if not unattended_install_items:
return False # didn't find any unattended installs
else:
msg = 'Installing available Apple Software Updates...'
restartneeded = self.IsRestartNeeded()
self._ResetMunkiStatusAndDisplayMessage(msg)
installlist = self.GetSoftwareUpdateInfo()
installresults = {'installed': [], 'download': []}
su_options = ['-i']
if only_unattended:
# Append list of unattended_install items
su_options.extend(unattended_install_items)
# Filter installist to only include items
# which we're attempting to install
installlist = [item for item in installlist
if item.get('productKey') in
unattended_install_product_ids]
else:
# We're installing all available updates; add all their names
for item in installlist:
su_options.append(
item['name'] + '-' + item['version_to_install'])
# new in 10.11: '--no-scan' flag to tell softwareupdate to just install
# and not rescan for available updates.
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
if os_version_tuple >= (10, 11):
su_options.append('--no-scan')
# 10.11 seems not to like file:// URLs, and we don't really need
# to switch to a local file URL anyway since we now have the
# --no-scan option
catalog_url = None
else:
# use our filtered local catalog
if not os.path.exists(self.local_catalog_path):
munkicommon.display_error(
'Missing local Software Update catalog at %s',
self.local_catalog_path)
return False # didn't do anything, so no restart needed
catalog_url = 'file://localhost' + urllib2.quote(
self.local_catalog_path)
retcode = self._RunSoftwareUpdate(
su_options, mode='install', catalog_url=catalog_url,
results=installresults)
if not 'InstallResults' in munkicommon.report:
munkicommon.report['InstallResults'] = []
munkicommon.display_debug1(
'Raw Apple Update install results: %s', installresults)
for item in installlist:
rep = {}
rep['name'] = item.get('apple_product_name')
rep['version'] = item.get('version_to_install', '')
rep['applesus'] = True
rep['time'] = NSDate.new()
rep['productKey'] = item.get('productKey', '')
message = 'Apple Software Update install of %s-%s: %s'
if rep['name'] in installresults['installed']:
rep['status'] = 0
install_status = 'SUCCESSFUL'
elif ('display_name' in item and
item['display_name'] in installresults['installed']):
rep['status'] = 0
install_status = 'SUCCESSFUL'
elif rep['name'] in installresults['download']:
rep['status'] = -1
install_status = 'FAILED due to missing package.'
munkicommon.display_warning(
'Apple update %s, %s failed. A sub-package was missing '
'on disk at time of install.'
% (rep['name'], rep['productKey']))
else:
rep['status'] = -2
install_status = 'FAILED for unknown reason'
munkicommon.display_warning(
'Apple update %s, %s may have failed to install. No record '
'of success or failure.', rep['name'], rep['productKey'])
if installresults['installed']:
munkicommon.display_warning(
'softwareupdate recorded these installations: %s',
installresults['installed'])
munkicommon.report['InstallResults'].append(rep)
log_msg = message % (rep['name'], rep['version'], install_status)
munkicommon.log(log_msg, 'Install.log')
if retcode: # there was an error
munkicommon.display_error('softwareupdate error: %s' % retcode)
# Refresh Applicable updates and catalogs
# since we may have performed some unattended installs
if only_unattended:
product_ids = self.GetAvailableUpdateProductIDs()
self._WriteFilteredCatalog(product_ids, self.filtered_catalog_path)
# clean up our now stale local cache
if os.path.exists(self.cache_dir) and not only_unattended:
# TODO(unassigned): change this to Pythonic delete.
dummy_retcode = subprocess.call(['/bin/rm', '-rf', self.cache_dir])
# remove the now invalid AppleUpdates.plist and AvailableUpdates.plist
self.ClearAppleUpdateInfo()
# Also clear our pref value for last check date. We may have
# just installed an update which is a pre-req for some other update.
# Let's check again soon.
munkicommon.set_pref('LastAppleSoftwareUpdateCheck', None)
# show stop button again
if munkicommon.munkistatusoutput:
munkistatus.showStopButton()
return restartneeded
def AppleSoftwareUpdatesAvailable(
self, force_check=False, suppress_check=False):
"""Checks for available Apple Software Updates, trying not to hit the
SUS more than needed.
Args:
force_check: Boolean. If True, forces a softwareupdate run.
suppress_check: Boolean. If True, skips a softwareupdate run.
Returns:
Integer. Count of available Apple updates.
"""
success = True
if suppress_check:
# typically because we're doing a logout install; if
# there are no waiting Apple Updates we shouldn't
# trigger a check for them.
pass
elif force_check:
# typically because user initiated the check from
# Managed Software Update.app
success = self.CheckForSoftwareUpdates(force_check=True)
else:
# have we checked recently? Don't want to check with
# Apple Software Update server too frequently
now = NSDate.new()
next_su_check = now
last_su_check_string = munkicommon.pref(
'LastAppleSoftwareUpdateCheck')
if last_su_check_string:
try:
last_su_check = NSDate.dateWithString_(
last_su_check_string)
# dateWithString_ returns None if invalid date string.
if not last_su_check:
raise ValueError
interval = 24 * 60 * 60 # only force check every 24 hours.
next_su_check = last_su_check.dateByAddingTimeInterval_(
interval)
except (ValueError, TypeError):
pass
if now.timeIntervalSinceDate_(next_su_check) >= 0:
success = self.CheckForSoftwareUpdates(force_check=True)
else:
success = self.CheckForSoftwareUpdates(force_check=False)
munkicommon.display_debug1(
'CheckForSoftwareUpdates result: %s' % success)
if success:
count = self.WriteAppleUpdatesFile()
else:
self.ClearAppleUpdateInfo()
return 0
if munkicommon.stopRequested():
return 0
return count
def SoftwareUpdateList(self):
"""Returns a list of str update names using softwareupdate -l."""
if self._update_list_cache is not None:
return self._update_list_cache
updates = []
munkicommon.display_detail(
'Getting list of available Apple Software Updates')
cmd = ['/usr/sbin/softwareupdate', '-l']
proc = subprocess.Popen(cmd, shell=False, bufsize=-1,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, dummy_err = proc.communicate()
if proc.returncode == 0:
updates = [str(item)[5:] for item in str(output).splitlines()
if str(item).startswith(' * ')]
munkicommon.display_detail(
'softwareupdate returned %d updates.', len(updates))
self._update_list_cache = updates
return updates
def copyUpdateMetadata(self, item, metadata):
"""Applies metadata to Apple update item restricted
to keys contained in 'metadata_to_copy'.
"""
# pylint: disable=no-self-use
metadata_to_copy = ['blocking_applications',
'description',
'display_name',
'force_install_after_date',
'unattended_install',
'RestartAction']
# Mapping of supported RestartActions to
# equal or greater auxiliary actions
RestartActions = {
'RequireRestart': ['RequireRestart', 'RecommendRestart'],
'RecommendRestart': ['RequireRestart', 'RecommendRestart'],
'RequireLogout': ['RequireRestart', 'RecommendRestart',
'RequireLogout'],
'None': ['RequireRestart', 'RecommendRestart',
'RequireLogout']
}
for key in metadata:
# Apply 'white-listed', non-empty metadata keys
if key in metadata_to_copy and metadata[key]:
if key == 'RestartAction':
# Ensure that a heavier weighted 'RestartAction' is not
# overridden by one supplied in metadata
if metadata[key] not in RestartActions.get(
item.get(key, 'None')):
munkicommon.display_debug2(
'\tSkipping metadata RestartAction\'%s\' '
'for item %s (ProductKey %s), '
'item\'s original \'%s\' is preferred.',
metadata[key], item.get('name'),
item.get('productKey'), item[key])
continue
elif key == 'unattended_install':
# Don't apply unattended_install if a RestartAction exists
# in either the original item or metadata
if metadata.get('RestartAction', 'None') != 'None':
munkicommon.display_warning(
'\tIgnoring unattended_install key for Apple '
'update %s (ProductKey %s) '
'because metadata RestartAction is %s.',
item.get('name'), item.get('productKey'),
metadata.get('RestartAction'))
continue
if item.get('RestartAction', 'None') != 'None':
munkicommon.display_warning(
'\tIgnoring unattended_install key for Apple '
'update %s (ProductKey %s) '
'because item RestartAction is %s.'
% (item.get('name'), item.get('productKey'),
item.get('RestartAction')))
continue
munkicommon.display_debug2('\tApplying %s...' % key)
item[key] = metadata[key]
return item
def GetUnattendedInstalls(self):
"""Processes AppleUpdates.plist to return a list
of NAME-VERSION formatted items and a list of product_ids
which are elgible for unattended installation.
"""
item_list = []
product_ids = []
try:
pl_dict = FoundationPlist.readPlist(self.apple_updates_plist)
except FoundationPlist.FoundationPlistException:
munkicommon.display_error(
'Error reading: %s', self.apple_updates_plist)
return item_list, product_ids
apple_updates = pl_dict.get('AppleUpdates', [])
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
for item in apple_updates:
if (item.get('unattended_install') or
(munkicommon.pref('UnattendedAppleUpdates') and
item.get('RestartAction', 'None') is 'None' and
os_version_tuple >= (10, 10))):
if munkicommon.blockingApplicationsRunning(item):
munkicommon.display_detail(
'Skipping unattended install of %s because '
'blocking application(s) running.'
% item['display_name'])
continue
install_item = item['name'] + '-' + item['version_to_install']
item_list.append(install_item)
product_ids.append(item['productKey'])
else:
munkicommon.display_detail(
'Skipping install of %s because it\'s not unattended.'
% item['display_name'])
return item_list, product_ids
# Make the new appleupdates module easily dropped in with exposed funcs
# for now.
apple_updates_object = None
def getAppleUpdatesInstance():
"""Returns either an AppleUpdates instance, either cached or new."""
global apple_updates_object
if apple_updates_object is None:
apple_updates_object = AppleUpdates()
return apple_updates_object
def softwareUpdateList():
"""Method for drop-in appleupdates replacement; see primary method docs."""
return getAppleUpdatesInstance().SoftwareUpdateList()
def clearAppleUpdateInfo():
"""Method for drop-in appleupdates replacement; see primary method docs."""
return getAppleUpdatesInstance().ClearAppleUpdateInfo()
def installAppleUpdates(only_unattended=False):
"""Method for drop-in appleupdates replacement; see primary method docs."""
return getAppleUpdatesInstance().InstallAppleUpdates(
only_unattended=only_unattended)
def appleSoftwareUpdatesAvailable(forcecheck=False, suppresscheck=False,
client_id='', forcecatalogrefresh=False):
"""Method for drop-in appleupdates replacement; see primary method docs."""
appleUpdatesObject = getAppleUpdatesInstance()
os_version_tuple = munkicommon.getOsVersion(as_tuple=True)
munkisuscatalog = munkicommon.pref('SoftwareUpdateServerURL')
if os_version_tuple >= (10, 11):
if munkisuscatalog:
munkicommon.display_warning(
"Custom softwareupate catalog %s in Munki's preferences will "
"be ignored." % munkisuscatalog)
elif appleUpdatesObject.CatalogURLisManaged():
munkicommon.display_warning(
"Cannot efficiently manage Apple Software updates because "
"softwareupdate's CatalogURL is managed via MCX or profiles. "
"You may see unexpected or undesirable results.")
appleUpdatesObject.client_id = client_id
appleUpdatesObject.force_catalog_refresh = forcecatalogrefresh
return appleUpdatesObject.AppleSoftwareUpdatesAvailable(
force_check=forcecheck, suppress_check=suppresscheck)
def displayAppleUpdateInfo():
"""Method for drop-in appleupdates replacement; see primary method docs."""
getAppleUpdatesInstance().DisplayAppleUpdateInfo()