Restructure appleupdates.py

This commit is contained in:
Greg Neagle
2017-01-06 13:22:33 -08:00
parent d0c06dabdc
commit ad1d523e24
5 changed files with 802 additions and 688 deletions

View File

@@ -0,0 +1 @@
from .core import *

View File

@@ -1,11 +1,5 @@
#!/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");
@@ -19,7 +13,12 @@ Utilities for dealing with Apple Software Update.
# 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.
"""
appleupdates.py
Utilities for dealing with Apple Software Update.
"""
import glob
import gzip
@@ -35,165 +34,52 @@ import urlparse
# pylint: disable=E0611
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
# pylint: enable=E0611
from . import catalogs
from . import appledistutils
from . import fetch
from . import launchd
from . import munkicommon
from . import munkistatus
from . import updatecheck
from . import FoundationPlist
from . import dist
from . import su_prefs
from . import sync
from ..updatecheck import catalogs
from .. import fetch
from .. import launchd
from .. import munkicommon
from .. import munkistatus
from .. import updatecheck
from .. import FoundationPlist
# 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.
"""Class for 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')
self.apple_updates_plist = os.path.join(
self._managed_install_dir, 'AppleUpdates.plist')
# 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()
su_prefs.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.applesync = sync.AppleUpdateSync()
self._update_list_cache = None
@@ -210,270 +96,6 @@ class AppleUpdates(object):
# 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.Error 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:
@@ -521,7 +143,7 @@ class AppleUpdates(object):
if os_version_tuple >= (10, 11):
catalog_url = None
else:
catalog_url = self._GetAppleCatalogURL()
catalog_url = self.applesync.GetAppleCatalogURL()
retcode = self._RunSoftwareUpdate(
['-d', '-a'], catalog_url=catalog_url, stop_allowed=True)
@@ -530,7 +152,7 @@ class AppleUpdates(object):
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(
last_session_successful = su_prefs.GetSoftwareUpdatePref(
'LastSessionSuccessful')
if last_session_successful is False:
munkicommon.display_error(
@@ -546,7 +168,7 @@ class AppleUpdates(object):
"""
# first, try to get the list from com.apple.SoftwareUpdate preferences
recommended_updates = self.GetSoftwareUpdatePref(
recommended_updates = su_prefs.GetSoftwareUpdatePref(
'RecommendedUpdates')
if recommended_updates:
return [item['Product Key'] for item in recommended_updates
@@ -567,89 +189,6 @@ class AppleUpdates(object):
"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.Error:
raise
def InstalledApplePackagesHaveChanged(self):
"""Generates a SHA-256 checksum of the info for all packages in the
@@ -686,7 +225,8 @@ class AppleUpdates(object):
Returns:
Boolean. True if a force check is needed, False otherwise.
"""
new_hash = munkicommon.getsha256hash(self.apple_download_catalog_path)
new_hash = munkicommon.getsha256hash(
self.applesync.apple_download_catalog_path)
if original_hash != new_hash:
munkicommon.log('Apple update catalog has changed.')
return True
@@ -712,13 +252,15 @@ class AppleUpdates(object):
Boolean. True if there are updates, False otherwise.
"""
before_hash = munkicommon.getsha256hash(
self.apple_download_catalog_path)
self.applesync.apple_download_catalog_path)
msg = 'Checking Apple Software Update catalog...'
self._ResetMunkiStatusAndDisplayMessage(msg)
try:
self.CacheAppleCatalog()
except CatalogNotFoundError:
self.applesync.CacheAppleCatalog()
except sync.CatalogNotFoundError:
return False
except (ReplicationError, fetch.Error) as err:
except (sync.ReplicationError, fetch.Error) as err:
munkicommon.display_warning(
'Could not download Apple SUS catalog:')
munkicommon.display_warning('\t%s', unicode(err))
@@ -731,28 +273,25 @@ class AppleUpdates(object):
'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
return bool(self.GetSoftwareUpdateInfo())
if self.DownloadAvailableUpdates(): # Success; ready to install.
munkicommon.set_pref('LastAppleSoftwareUpdateCheck', NSDate.date())
product_ids = self.GetAvailableUpdateProductIDs()
if not product_ids:
# No updates found
# TO-DO: clear metadata cache
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
self.applesync.WriteFilteredCatalog(product_ids)
try:
self.applesync.CacheUpdateMetadata(product_ids)
except sync.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.
@@ -813,7 +352,7 @@ class AppleUpdates(object):
apple_updates = []
# first, try to get the list from com.apple.SoftwareUpdate preferences
recommended_updates = self.GetSoftwareUpdatePref(
recommended_updates = su_prefs.GetSoftwareUpdatePref(
'RecommendedUpdates')
if recommended_updates:
for item in recommended_updates:
@@ -846,52 +385,45 @@ class AppleUpdates(object):
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 = appledistutils.parse_su_dist(
english_dist)
su_info = appledistutils.parse_su_dist(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)
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.applesync.GetDistributionForProductKey(
product_key)
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.applesync.GetDistributionForProductKey(
product_key, 'English')
if english_dist:
english_su_info = dist.parse_su_dist(
english_dist)
su_info = dist.parse_su_dist(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
@@ -958,123 +490,6 @@ class AppleUpdates(object):
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):
@@ -1124,7 +539,7 @@ class AppleUpdates(object):
if os_version_tuple < (10, 9):
cmd.extend(['--CatalogURL', catalog_url])
elif os_version_tuple in [(10, 9), (10, 10)]:
self._SetCustomCatalogURL(catalog_url)
su_prefs.SetCustomCatalogURL(catalog_url)
cmd.extend(options_list)
@@ -1231,12 +646,12 @@ class AppleUpdates(object):
if catalog_url:
# reset CatalogURL if needed
if os_version_tuple in [(10, 9), (10, 10)]:
self._ResetOriginalCatalogURL()
su_prefs.ResetOriginalCatalogURL()
retcode = job.returncode()
if retcode == 0:
# get SoftwareUpdate's LastResultCode
last_result_code = self.GetSoftwareUpdatePref(
last_result_code = su_prefs.GetSoftwareUpdatePref(
'LastResultCode') or 0
if last_result_code > 2:
retcode = last_result_code
@@ -1246,9 +661,6 @@ class AppleUpdates(object):
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.
@@ -1309,13 +721,13 @@ class AppleUpdates(object):
catalog_url = None
else:
# use our filtered local catalog
if not os.path.exists(self.local_catalog_path):
if not os.path.exists(self.applesync.local_catalog_path):
munkicommon.display_error(
'Missing local Software Update catalog at %s',
self.local_catalog_path)
self.applesync.local_catalog_path)
return False # didn't do anything, so no restart needed
catalog_url = 'file://localhost' + urllib2.quote(
self.local_catalog_path)
self.applesync.local_catalog_path)
retcode = self._RunSoftwareUpdate(
su_options, mode='install', catalog_url=catalog_url,
@@ -1369,12 +781,11 @@ class AppleUpdates(object):
# since we may have performed some unattended installs
if only_unattended:
product_ids = self.GetAvailableUpdateProductIDs()
self._WriteFilteredCatalog(product_ids, self.filtered_catalog_path)
self.applesync.WriteFilteredCatalog(product_ids)
# 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])
if not only_unattended:
self.applesync.clean_up_cache()
# remove the now invalid AppleUpdates.plist and AvailableUpdates.plist
self.ClearAppleUpdateInfo()
# Also clear our pref value for last check date. We may have
@@ -1549,8 +960,6 @@ class AppleUpdates(object):
# for now.
apple_updates_object = None
def getAppleUpdatesInstance():
"""Returns either an AppleUpdates instance, either cached or new."""
global apple_updates_object
@@ -1581,7 +990,7 @@ def appleSoftwareUpdatesAvailable(forcecheck=False, suppresscheck=False,
munkicommon.display_warning(
"Custom softwareupate catalog %s in Munki's preferences will "
"be ignored." % munkisuscatalog)
elif appleUpdatesObject.CatalogURLisManaged():
elif su_prefs.CatalogURLisManaged():
munkicommon.display_warning(
"Cannot efficiently manage Apple Software updates because "
"softwareupdate's CatalogURL is managed via MCX or profiles. "

View File

@@ -15,7 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
appledistutils.py
dist.py
Created by Greg Neagle on 2017-01-04.
@@ -34,9 +34,9 @@ from AppKit import NSAttributedString
from LaunchServices import LSFindApplicationForInfo
# pylint: enable=E0611
from . import display
from . import pkgutils
from . import FoundationPlist
from .. import display
from .. import pkgutils
from .. import FoundationPlist
def get_restart_action(restart_action_list):

View File

@@ -0,0 +1,171 @@
#!/usr/bin/python
# encoding: utf-8
#
# Copyright 2009-2017 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.
"""
su_prefs.py
Created by Greg Neagle on 2017-01-06.
Utilities for working with Apple software update preferences
"""
import subprocess
# PyLint cannot properly find names inside Cocoa libraries, so issues bogus
# No name 'Foo' in module 'Bar' warnings. Disable them.
# pylint: disable=no-name-in-module
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
# pylint: enable=no-name-in-module
from .. import display
from .. import osutils
# Preference domain for Apple Software Update.
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN = 'com.apple.SoftwareUpdate'
# prefs key to store original catalog URL
ORIGINAL_CATALOG_URL_KEY = '_OriginalCatalogURL'
def GetSoftwareUpdatePref(pref_name):
"""Returns a preference from com.apple.SoftwareUpdate.
Uses CoreFoundation.
Args:
pref_name: str preference name to get.
"""
return CFPreferencesCopyAppValue(
pref_name, APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN)
def CatalogURLisManaged():
"""Returns True if Software Update's CatalogURL is managed
via MCX or Profiles"""
return CFPreferencesAppValueIsForced(
'CatalogURL', APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN)
def GetCatalogURL():
"""Returns Software Update's CatalogURL"""
return CFPreferencesCopyValue(
'CatalogURL',
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
def SetCustomCatalogURL(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 ORIGINAL_CATALOG_URL_KEY not in software_update_key_list:
# store the original CatalogURL
original_catalog_url = GetCatalogURL()
if not original_catalog_url:
# can't store None as a CFPreference
original_catalog_url = ""
CFPreferencesSetValue(
ORIGINAL_CATALOG_URL_KEY,
original_catalog_url,
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
# now set our custom CatalogURL
os_version_tuple = osutils.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):
display.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:
display.display_detail(output)
if err:
display.display_error(err)
def ResetOriginalCatalogURL():
"""Resets SoftwareUpdate's CatalogURL to the original value"""
software_update_key_list = CFPreferencesCopyKeyList(
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost) or []
if ORIGINAL_CATALOG_URL_KEY not in software_update_key_list:
# do nothing
return
original_catalog_url = CFPreferencesCopyValue(
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 = osutils.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:
display.display_detail(output)
if err:
display.display_error(err)
# remove ORIGINAL_CATALOG_URL_KEY
CFPreferencesSetValue(
ORIGINAL_CATALOG_URL_KEY, None,
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
# sync
if not CFPreferencesSynchronize(
APPLE_SOFTWARE_UPDATE_PREFS_DOMAIN,
kCFPreferencesAnyUser, kCFPreferencesCurrentHost):
display.display_error(
'Error resetting com.apple.SoftwareUpdate CatalogURL.')
if __name__ == '__main__':
print 'This is a library of support tools for the Munki Suite.'

View File

@@ -0,0 +1,533 @@
#!/usr/bin/python
# encoding: utf-8
#
# Copyright 2009-2017 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.
"""
sync.py
Created by Greg Neagle on 2017-01-06.
Utilities for replicating and retreiving Apple software update metadata
"""
import gzip
import os
import subprocess
import time
import urllib2
import urlparse
# PyLint cannot properly find names inside Cocoa libraries, so issues bogus
# No name 'Foo' in module 'Bar' warnings. Disable them.
# pylint: disable=E0611
from Foundation import NSBundle
# pylint: enable=E0611
from . import su_prefs
from .. import display
from .. import fetch
from .. import info
from .. import osutils
from .. import prefs
from .. import processes
from .. import FoundationPlist
# 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'
# Path to the directory where local catalogs are stored, relative to
# prefs.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 AppleUpdateSync(object):
'''Object that handles local replication of Apple Software Update data'''
def __init__(self):
'''Set 'em all up '''
real_cache_dir = os.path.join(prefs.pref('ManagedInstallDir'), 'swupd')
if os.path.exists(real_cache_dir):
if not os.path.isdir(real_cache_dir):
display.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_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)
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.
"""
os_version_tuple = osutils.getOsVersion(as_tuple=True)
# Prefer Munki's preferences file in OS X <= 10.10
munkisuscatalog = prefs.pref('SoftwareUpdateServerURL')
if munkisuscatalog:
if os_version_tuple < (10, 11):
# only pay attention to Munki's SoftwareUpdateServerURL pref
# in 10.10 and earlier
return munkisuscatalog
# Otherwise prefer MCX or /Library/Preferences/com.apple.SoftwareUpdate
prefs_catalog_url = su_prefs.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 = osutils.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 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)
fileref = _open(self.apple_download_catalog_path, 'rb')
magic = fileref.read(2)
contents = ''
if magic == '\x1f\x8b': # File is gzip compressed.
fileref.close() # Close the open handle first.
fileref = gzip.open(self.apple_download_catalog_path, 'rb')
else: # Hopefully a nice plain plist.
fileref.seek(0)
contents = fileref.read()
fileref.close()
fileref = _open(local_apple_sus_catalog, 'wb')
fileref.write(contents)
fileref.close()
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:
display.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)
display.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.Error:
raise
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.Error 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 = info.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, product_ids):
"""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.extracted_catalog_path)
if not 'Products' in catalog:
display.display_warning(
'"Products" not found in %s',
self.extracted_catalog_path)
return
for product_key in product_ids:
if processes.stopRequested():
break
display.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 processes.stopRequested():
break
if 'MetadataURL' in package:
display.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:
# display.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 processes.stopRequested():
break
display.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:
display.display_warning(
'Could not cache %s distribution for product ID %s',
dist_lang, product_key)
if processes.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 = (
prefs.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, 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.'''
os_version_tuple = osutils.getOsVersion(as_tuple=True)
if os_version_tuple < (10, 11):
# use our filtered catalog
sucatalog = self.local_catalog_path
else:
# use the cached Apple catalog
sucatalog = self.extracted_catalog_path
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:
display.display_error(
'Could not retrieve %s: %s', url, err)
return None
def WriteFilteredCatalog(self, product_ids):
"""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, self.filtered_catalog_path)
def clean_up_cache(self):
"""Clean up our cache dir"""
if os.path.exists(self.cache_dir):
# TODO(unassigned): change this to Pythonic delete.
dummy_retcode = subprocess.call(['/bin/rm', '-rf', self.cache_dir])
if __name__ == '__main__':
print 'This is a library of support tools for the Munki Suite.'