Files
munki/code/client/munkilib/installinfo.py
2025-01-25 15:35:09 -08:00

364 lines
14 KiB
Python

# encoding: utf-8
#
# Copyright 2009-2025 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.
"""
installinfo.py
Created by Greg Neagle on 2017-01-01.
Functions for getting data from the InstallInfo.plist, etc
"""
# This code is largely still compatible with Python 2, so for now, turn off
# Python 3 style warnings
# pylint: disable=consider-using-f-string
# pylint: disable=redundant-u-string-prefix
from __future__ import absolute_import, print_function
# standard libs
import os
# Apple's libs
# PyLint cannot properly find names inside Cocoa libraries, so issues bogus
# No name 'Foo' in module 'Bar' warnings. Disable them.
# pylint: disable=E0611,E0401
from Foundation import NSDate
# pylint: enable=E0611,E0401
# our libs
from . import display
from . import info
from . import osinstaller
from . import prefs
from . import reports
from . import FoundationPlist
try:
_ = xrange # pylint: disable=xrange-builtin
except NameError:
# no xrange in Python 3
xrange = range # pylint: disable=redefined-builtin,invalid-name
# This many hours before a force install deadline, start notifying the user.
FORCE_INSTALL_WARNING_HOURS = 4
def get_installinfo():
'''Returns the dictionary describing the managed installs and removals'''
managedinstallbase = prefs.pref('ManagedInstallDir')
plist = {}
installinfo = os.path.join(managedinstallbase, 'InstallInfo.plist')
if os.path.exists(installinfo):
try:
plist = FoundationPlist.readPlist(installinfo)
except FoundationPlist.NSPropertyListSerializationException:
pass
return plist
def get_appleupdates():
'''Returns any available Apple updates'''
managedinstallbase = prefs.pref('ManagedInstallDir')
plist = {}
appleupdatesfile = os.path.join(managedinstallbase, 'AppleUpdates.plist')
if (os.path.exists(appleupdatesfile) and
(prefs.pref('InstallAppleSoftwareUpdates') or
prefs.pref('AppleSoftwareUpdatesOnly'))):
try:
plist = FoundationPlist.readPlist(appleupdatesfile)
except FoundationPlist.NSPropertyListSerializationException:
pass
return plist
def oldest_pending_update_in_days():
'''Return the datestamp of the oldest pending update'''
pendingupdatespath = os.path.join(
prefs.pref('ManagedInstallDir'), 'UpdateNotificationTracking.plist')
try:
pending_updates = FoundationPlist.readPlist(pendingupdatespath)
except FoundationPlist.NSPropertyListSerializationException:
return 0
oldest_date = now = NSDate.date()
for category in pending_updates:
for name in pending_updates[category]:
this_date = pending_updates[category][name]
if this_date < oldest_date:
oldest_date = this_date
return now.timeIntervalSinceDate_(oldest_date) / (24 * 60 * 60)
def get_pending_update_info():
'''Returns a dict with some data managedsoftwareupdate records at the end
of a run'''
data = {}
installinfo = get_installinfo()
data['install_count'] = len(installinfo.get('managed_installs', []))
data['removal_count'] = len(installinfo.get('removals', []))
appleupdates = get_appleupdates()
data['apple_update_count'] = len(appleupdates.get('AppleUpdates', []))
data['PendingUpdateCount'] = (data['install_count'] + data['removal_count']
+ data['apple_update_count'])
data['OldestUpdateDays'] = oldest_pending_update_in_days()
# calculate earliest date a forced install is due
installs = installinfo.get('managed_installs', [])
installs.extend(appleupdates.get('AppleUpdates', []))
earliest_date = None
for install in installs:
this_force_install_date = install.get('force_install_after_date')
if this_force_install_date:
try:
this_force_install_date = info.subtract_tzoffset_from_date(
this_force_install_date)
if not earliest_date or this_force_install_date < earliest_date:
earliest_date = this_force_install_date
except ValueError:
# bad date!
pass
data['ForcedUpdateDueDate'] = earliest_date
return data
def get_appleupdates_with_history():
'''Attempt to find the date Apple Updates were first seen since they can
appear and disappear from the list of available updates, which screws up
our tracking of pending updates that can trigger more aggressive update
notifications.
Returns a dict.'''
now = NSDate.date()
managed_install_dir = prefs.pref('ManagedInstallDir')
appleupdatehistorypath = os.path.join(
managed_install_dir, 'AppleUpdateHistory.plist')
appleupdatesinfo = get_appleupdates().get('AppleUpdates', [])
history_info = {}
if appleupdatesinfo:
try:
appleupdateshistory = FoundationPlist.readPlist(
appleupdatehistorypath)
except FoundationPlist.NSPropertyListSerializationException:
appleupdateshistory = {}
history_updated = False
for item in appleupdatesinfo:
product_key = item.get('productKey')
if not product_key:
continue
if product_key in appleupdateshistory:
history_info[item['name']] = (
appleupdateshistory[product_key].get('firstSeen', now))
else:
history_info[item['name']] = now
# record this for the future
appleupdateshistory[product_key] = {
'firstSeen': now,
'displayName': item.get('display_name', ''),
'version': item.get('version_to_install', '')
}
history_updated = True
if history_updated:
try:
FoundationPlist.writePlist(
appleupdateshistory, appleupdatehistorypath)
except FoundationPlist.NSPropertyListWriteException:
# we tried! oh well
pass
return history_info
def save_pending_update_times():
'''Record the time each update first is made available. We can use this to
escalate our notifications if there are items that have been skipped a lot
'''
now = NSDate.date()
managed_install_dir = prefs.pref('ManagedInstallDir')
pendingupdatespath = os.path.join(
managed_install_dir, 'UpdateNotificationTracking.plist')
installinfo = get_installinfo()
install_names = [item['name']
for item in installinfo.get('managed_installs', [])]
removal_names = [item['name']
for item in installinfo.get('removals', [])]
apple_updates = get_appleupdates_with_history()
staged_os_update_names = []
staged_os_update = osinstaller.get_staged_os_installer_info()
if staged_os_update and "name" in staged_os_update:
staged_os_update_names = [staged_os_update["name"]]
update_names = {
'managed_installs': install_names,
'removals': removal_names,
'AppleUpdates': apple_updates.keys(),
'StagedOSUpdates': staged_os_update_names
}
try:
prior_pending_updates = FoundationPlist.readPlist(pendingupdatespath)
except FoundationPlist.NSPropertyListSerializationException:
prior_pending_updates = {}
current_pending_updates = {}
for category in update_names:
current_pending_updates[category] = {}
for name in update_names[category]:
if (category in prior_pending_updates and
name in prior_pending_updates[category]):
# copy the prior datetime from matching item
current_pending_updates[category][name] = prior_pending_updates[
category][name]
else:
if category == 'AppleUpdates':
current_pending_updates[category][name] = apple_updates[name]
else:
# record new item with current datetime
current_pending_updates[category][name] = now
try:
FoundationPlist.writePlist(current_pending_updates, pendingupdatespath)
except FoundationPlist.NSPropertyListWriteException:
# we tried! oh well
pass
def display_update_info():
'''Prints info about available updates'''
def display_and_record_restart_info(item):
'''Displays logout/restart info for item if present and also updates
our report'''
if (item.get('RestartAction') == 'RequireRestart' or
item.get('RestartAction') == 'RecommendRestart'):
display.display_info(' *Restart required')
reports.report['RestartRequired'] = True
if item.get('RestartAction') == 'RequireLogout':
display.display_info(' *Logout required')
reports.report['LogoutRequired'] = True
'''Displays force install deadline if present'''
if item.get('force_install_after_date'):
force_install_after_date = str(item['force_install_after_date'])[0:-6]
display.display_info(' *Must be installed by %s',
force_install_after_date)
installinfo = get_installinfo()
installcount = len(installinfo.get('managed_installs', []))
removalcount = len(installinfo.get('removals', []))
if installcount:
display.display_info('')
display.display_info(
'The following items will be installed or upgraded:')
for item in installinfo.get('managed_installs', []):
if item.get('installer_item'):
display.display_info(
' + %s-%s', item.get('name', ''),
item.get('version_to_install', ''))
if item.get('description'):
display.display_info(' %s', item['description'])
display_and_record_restart_info(item)
if removalcount:
display.display_info('The following items will be removed:')
for item in installinfo.get('removals', []):
if item.get('installed'):
display.display_info(' - %s', item.get('name'))
display_and_record_restart_info(item)
if installcount == 0 and removalcount == 0:
display.display_info(
'No changes to managed software are available.')
def force_install_package_check():
"""Check installable packages and applicable Apple updates
for force install parameters.
This method modifies InstallInfo and/or AppleUpdates in one scenario:
It enables the unattended_install flag on all packages which need to be
force installed and do not have a RestartAction.
The return value may be one of:
'now': a force install is about to occur
'soon': a force install will occur within FORCE_INSTALL_WARNING_HOURS
'logout': a force install is about to occur and requires logout
'restart': a force install is about to occur and requires restart
None: no force installs are about to occur
"""
result = None
managed_install_dir = prefs.pref('ManagedInstallDir')
installinfo_types = {'InstallInfo.plist': 'managed_installs'}
if (prefs.pref('InstallAppleSoftwareUpdates') or
prefs.pref('AppleSoftwareUpdatesOnly')):
# only consider Apple updates if the prefs say it's OK
installinfo_types['AppleUpdates.plist'] = 'AppleUpdates'
now = NSDate.date()
now_xhours = NSDate.dateWithTimeIntervalSinceNow_(
FORCE_INSTALL_WARNING_HOURS * 3600)
for installinfo_plist in installinfo_types:
pl_dict = installinfo_types[installinfo_plist]
installinfopath = os.path.join(managed_install_dir, installinfo_plist)
try:
installinfo = FoundationPlist.readPlist(installinfopath)
except FoundationPlist.NSPropertyListSerializationException:
continue
writeback = False
for i in xrange(len(installinfo.get(pl_dict, []))):
install = installinfo[pl_dict][i]
force_install_after_date = install.get('force_install_after_date')
if not force_install_after_date:
continue
force_install_after_date = (
info.subtract_tzoffset_from_date(force_install_after_date))
display.display_debug1(
'Forced install for %s at %s',
install['name'], force_install_after_date)
if now >= force_install_after_date:
result = 'now'
if install.get('RestartAction'):
if install['RestartAction'] == 'RequireLogout':
result = 'logout'
elif (install['RestartAction'] == 'RequireRestart' or
install['RestartAction'] == 'RecommendRestart'):
result = 'restart'
elif not install.get('unattended_install', False):
display.display_debug1(
'Setting unattended install for %s', install['name'])
install['unattended_install'] = True
installinfo[pl_dict][i] = install
writeback = True
if not result and now_xhours >= force_install_after_date:
result = 'soon'
if writeback:
FoundationPlist.writePlist(installinfo, installinfopath)
return result
if __name__ == '__main__':
print('This is a library of support tools for the Munki Suite.')