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

599 lines
25 KiB
Python

# encoding: utf-8
#
# Copyright 2017-2020 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.
"""
osinstaller.py
Created by Greg Neagle on 2017-03-29.
Support for using startosinstall to install macOS.
"""
from __future__ import absolute_import, print_function
# stdlib imports
import os
import signal
import subprocess
import time
# 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 CFPreferencesSetValue
from Foundation import kCFPreferencesAnyUser
from Foundation import kCFPreferencesCurrentHost
# pylint: enable=E0611
# our imports
import munkilib.authrestart.client as authrestartd
from . import FoundationPlist
from . import authrestart
from . import bootstrapping
from . import display
from . import dmgutils
from . import launchd
from . import munkilog
from . import munkistatus
from . import osutils
from . import pkgutils
from . import prefs
from . import processes
from . import scriptutils
def boot_volume_is_cs_converting():
'''Returns True if the boot volume is in the middle of a CoreStorage
conversion from encrypted to decrypted or vice-versa. macOS installs fail
in this state.'''
try:
output = subprocess.check_output(
['/usr/sbin/diskutil', 'cs', 'info', '-plist', '/'])
except subprocess.CalledProcessError:
# diskutil cs info returns error if volume is not CoreStorage
return False
try:
csinfo_plist = FoundationPlist.readPlistFromString(output)
except FoundationPlist.FoundationPlistException:
return False
conversion_state = csinfo_plist.get(
'CoreStorageLogicalVolumeConversionState')
return conversion_state == 'Converting'
def find_install_macos_app(dir_path):
'''Returns the path to the first Install macOS.app found the top level of
dir_path, or None'''
for item in osutils.listdir(dir_path):
item_path = os.path.join(dir_path, item)
startosinstall_path = os.path.join(
item_path, 'Contents/Resources/startosinstall')
if os.path.exists(startosinstall_path):
return item_path
# if we get here we didn't find one
return None
def install_macos_app_is_stub(app_path):
'''High Sierra downloaded installer is sometimes a "stub" application that
does not contain the InstallESD.dmg. Return True if the given app path is
missing the InstallESD.dmg and missing SharedSupport.dmg (new in Big Sur)'''
installesd_dmg = os.path.join(
app_path, 'Contents/SharedSupport/InstallESD.dmg')
sharedsupport_dmg = os.path.join(
app_path, 'Contents/SharedSupport/SharedSupport.dmg')
return not (os.path.exists(installesd_dmg) or
os.path.exists(sharedsupport_dmg))
def get_os_version(app_path):
'''Returns the os version from the OS Installer app'''
installinfo_plist = os.path.join(
app_path, 'Contents/SharedSupport/InstallInfo.plist')
if os.path.isfile(installinfo_plist):
try:
info = FoundationPlist.readPlist(installinfo_plist)
return info['System Image Info']['version']
except (FoundationPlist.FoundationPlistException,
IOError, KeyError, AttributeError, TypeError):
return ''
sharedsupport_dmg = os.path.join(
app_path, 'Contents/SharedSupport/SharedSupport.dmg')
if os.path.isfile(sharedsupport_dmg):
# starting with macOS Big Sur
mountpoints = dmgutils.mountdmg(sharedsupport_dmg)
if mountpoints:
info_plist_path = os.path.join(
mountpoints[0],
"com_apple_MobileAsset_MacSoftwareUpdate",
"com_apple_MobileAsset_MacSoftwareUpdate.xml"
)
try:
info = FoundationPlist.readPlist(info_plist_path)
return info['Assets'][0]['OSVersion']
except FoundationPlist.FoundationPlistException:
return ''
finally:
dmgutils.unmountdmg(mountpoints[0])
return ''
def setup_authrestart_if_applicable():
'''Sets up the ability to do an authrestart if applicable'''
# ask authrestartd if we can do an auth restart, or look for a recovery
# key (via munkilib.authrestart methods)
if (authrestartd.verify_can_attempt_auth_restart() or
authrestart.can_attempt_auth_restart()):
display.display_info(
'FileVault is active and we can do an authrestart')
#os_version_tuple = osutils.getOsVersion(as_tuple=True)
if False: # was: os_version_tuple >= (10, 12):
# setup delayed auth restart so that when startosinstall does a
# restart, it completes without user credentials
display.display_info('Setting up delayed authrestart...')
authrestartd.setup_delayed_authrestart()
# make sure the special secret InstallAssistant preference is not
# set
CFPreferencesSetValue(
'IAQuitInsteadOfReboot', None, '.GlobalPreferences',
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
else:
#
# set an undocumented preference to tell the osinstaller
# process to exit instead of restart
# this is the equivalent of:
# `defaults write /Library/Preferences/.GlobalPreferences
# IAQuitInsteadOfReboot -bool YES`
#
# This preference is referred to in a framework inside the
# Install macOS.app:
# Contents/Frameworks/OSInstallerSetup.framework/Versions/A/
# Frameworks/OSInstallerSetupInternal.framework/Versions/A/
# OSInstallerSetupInternal
#
# It might go away in future versions of the macOS installer.
#
display.display_info(
'Configuring startosinstall to quit instead of restart...')
CFPreferencesSetValue(
'IAQuitInsteadOfReboot', True, '.GlobalPreferences',
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
class StartOSInstallError(Exception):
'''Exception to raise if starting the macOS install fails'''
pass
class StartOSInstallRunner(object):
'''Handles running startosinstall to set up and kick off an upgrade install
of macOS'''
def __init__(self, installer, finishing_tasks=None, installinfo=None):
self.installer = installer
self.installinfo = installinfo
self.finishing_tasks = finishing_tasks
self.dmg_mountpoint = None
self.got_sigusr1 = False
def sigusr1_handler(self, _signum, _frame):
'''Signal handler for SIGUSR1 from startosinstall, which tells us it's
done setting up the macOS install and is ready and waiting to reboot'''
display.display_debug1('Got SIGUSR1 from startosinstall')
self.got_sigusr1 = True
setup_authrestart_if_applicable()
# set Munki to run at boot after the OS upgrade is complete
try:
bootstrapping.set_bootstrap_mode()
except bootstrapping.SetupError as err:
display.display_error(
'Could not set up Munki to run after OS upgrade is complete: '
'%s', err)
# do cleanup, record-keeping, notifications
if self.installinfo and 'postinstall_script' in self.installinfo:
# run the postinstall_script
dummy_retcode = scriptutils.run_embedded_script(
'postinstall_script', self.installinfo)
if self.finishing_tasks:
self.finishing_tasks()
if pkgutils.hasValidDiskImageExt(self.installer):
# remove the diskimage to free up more space for the actual install
try:
os.unlink(self.installer)
except (IOError, OSError):
pass
# now tell startosinstall it's OK to proceed
subprocess.call(['/usr/bin/killall', '-SIGUSR1', 'startosinstall'])
def get_app_path(self, itempath):
'''Mounts dmgpath and returns path to the Install macOS.app'''
if itempath.endswith('.app'):
return itempath
if pkgutils.hasValidDiskImageExt(itempath):
display.display_status_minor(
'Mounting disk image %s' % os.path.basename(itempath))
mountpoints = dmgutils.mountdmg(itempath, random_mountpoint=False)
if mountpoints:
# look in the first mountpoint for apps
self.dmg_mountpoint = mountpoints[0]
app_path = find_install_macos_app(self.dmg_mountpoint)
if app_path:
# leave dmg mounted
return app_path
# if we get here we didn't find an Install macOS.app with the
# expected contents
dmgutils.unmountdmg(self.dmg_mountpoint)
self.dmg_mountpoint = None
raise StartOSInstallError(
'Valid Install macOS.app not found on %s' % itempath)
else:
raise StartOSInstallError(
u'No filesystems mounted from %s' % itempath)
else:
raise StartOSInstallError(
u'%s doesn\'t appear to be an application or disk image'
% itempath)
def start(self):
'''Starts a macOS install from an Install macOS.app stored at the root
of a disk image, or from a locally installed Install macOS.app.
Will always reboot after if the setup is successful.
Therefore this must be done at the end of all other actions that Munki
performs during a managedsoftwareupdate run.'''
if boot_volume_is_cs_converting():
raise StartOSInstallError(
'Skipping macOS upgrade because the boot volume is in the '
'middle of a CoreStorage conversion.')
if self.installinfo and 'preinstall_script' in self.installinfo:
# run the preinstall_script
retcode = scriptutils.run_embedded_script(
'preinstall_script', self.installinfo)
if retcode:
# don't install macOS, return failure
raise StartOSInstallError(
'Skipping macOS upgrade due to preinstall_script error.')
# set up our signal handler
signal.signal(signal.SIGUSR1, self.sigusr1_handler)
# get our tool paths
app_path = self.get_app_path(self.installer)
startosinstall_path = os.path.join(
app_path, 'Contents/Resources/startosinstall')
os_vers_to_install = get_os_version(app_path)
# run startosinstall via subprocess
# we need to wrap our call to startosinstall with a utility
# that makes startosinstall think it is connected to a tty-like
# device so its output is unbuffered so we can get progress info
# otherwise we get nothing until the process exits.
#
# 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([startosinstall_path,
'--agreetolicense',
'--rebootdelay', '300',
'--pidtosignal', str(os.getpid())])
if pkgutils.MunkiLooseVersion(
os_vers_to_install) < pkgutils.MunkiLooseVersion('10.14'):
# --applicationpath option is _required_ in Sierra and early
# releases of High Sierra. It became optional (or is ignored?) in
# later releases of High Sierra and causes warnings in Mojave
# so don't add this option when installing Mojave
cmd.extend(['--applicationpath', app_path])
if pkgutils.MunkiLooseVersion(
os_vers_to_install) < pkgutils.MunkiLooseVersion('10.12.4'):
# --volume option is _required_ prior to 10.12.4 installer
# and must _not_ be included in 10.12.4+ installer's startosinstall
cmd.extend(['--volume', '/'])
if pkgutils.MunkiLooseVersion(
os_vers_to_install) < pkgutils.MunkiLooseVersion('10.13.5'):
# --nointeraction is an undocumented option that appears to be
# not only no longer needed/useful but seems to trigger some issues
# in more recent releases
cmd.extend(['--nointeraction'])
if (self.installinfo and
'additional_startosinstall_options' in self.installinfo):
cmd.extend(self.installinfo['additional_startosinstall_options'])
# more magic to get startosinstall to not buffer its output for
# percent complete
env = {'NSUnbufferedIO': 'YES'}
try:
job = launchd.Job(cmd, environment_vars=env, cleanup_at_exit=False)
job.start()
except launchd.LaunchdJobException as err:
display.display_error(
'Error with launchd job (%s): %s', cmd, err)
display.display_error('Aborting startosinstall run.')
raise StartOSInstallError(err)
startosinstall_output = []
timeout = 2 * 60 * 60
inactive = 0
while True:
if processes.stop_requested():
job.stop()
break
info_output = job.stdout.readline()
if not info_output:
if job.returncode() is not None:
break
else:
# no data, but we're still running
inactive += 1
if inactive >= timeout:
# no output for too long, kill the job
display.display_error(
"startosinstall timeout after %d seconds"
% timeout)
job.stop()
break
# sleep a bit before checking for more output
time.sleep(1)
continue
# we got non-empty output, reset inactive timer
inactive = 0
info_output = info_output.decode('UTF-8')
# save all startosinstall output in case there is
# an error so we can dump it to the log
startosinstall_output.append(info_output)
# parse output for useful progress info
msg = info_output.strip()
if msg.startswith('Preparing to '):
display.display_status_minor(msg)
elif msg.startswith(('Preparing ', 'Preparing: ')):
# percent-complete messages
percent_str = msg.split()[-1].rstrip('%.')
try:
percent = int(float(percent_str))
except ValueError:
percent = -1
display.display_percent_done(percent, 100)
elif msg.startswith(('By using the agreetolicense option',
'If you do not agree,')):
# annoying legalese
pass
elif msg.startswith('Helper tool cr'):
# no need to print that stupid message to screen!
# 10.12: 'Helper tool creashed'
# 10.13: 'Helper tool crashed'
munkilog.log(msg)
elif msg.startswith(
('Signaling PID:', 'Waiting to reboot',
'Process signaled okay')):
# messages around the SIGUSR1 signalling
display.display_debug1('startosinstall: %s', msg)
elif msg.startswith('System going down for install'):
display.display_status_minor(
'System will restart and begin upgrade of macOS.')
else:
# none of the above, just display
display.display_status_minor(msg)
# startosinstall exited
munkistatus.percent(100)
retcode = job.returncode()
# previously we unmounted the disk image, but since we're going to
# restart very very soon, don't bother
#if self.dmg_mountpoint:
# dmgutils.unmountdmg(self.dmg_mountpoint)
if retcode and not (retcode == 255 and self.got_sigusr1):
# append stderr to our startosinstall_output
if job.stderr:
startosinstall_output.extend(job.stderr.read().splitlines())
display.display_status_minor(
"Starting macOS install failed with return code %s" % retcode)
display.display_error("-"*78)
for line in startosinstall_output:
display.display_error(line.rstrip("\n"))
display.display_error("-"*78)
raise StartOSInstallError(
'startosinstall failed with return code %s' % retcode)
elif self.got_sigusr1:
# startosinstall got far enough along to signal us it was ready
# to finish and reboot, so we can believe it was successful
munkilog.log('macOS install successfully set up.')
munkilog.log(
'Starting macOS install of %s: SUCCESSFUL' % os_vers_to_install,
'Install.log')
# previously we checked if retcode == 255:
# that may have been something specific to 10.12's startosinstall
# if startosinstall exited after sending us sigusr1 we should
# handle the restart.
if retcode not in (0, 255):
# some logging for possible investigation in the future
munkilog.log('startosinstall exited %s' % retcode)
munkilog.log('startosinstall quit instead of rebooted; we will '
'do restart.')
# clear our special secret InstallAssistant preference
CFPreferencesSetValue(
'IAQuitInsteadOfReboot', None, '.GlobalPreferences',
kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
# attempt to do an auth restart, or regular restart, or shutdown
if not authrestartd.restart():
authrestart.do_authorized_or_normal_restart(
shutdown=osutils.bridgeos_update_staged())
else:
raise StartOSInstallError(
'startosinstall did not complete successfully. '
'See /var/log/install.log for details.')
def get_catalog_info(mounted_dmgpath):
'''Returns catalog info (pkginfo) for a macOS installer on a disk
image'''
app_path = find_install_macos_app(mounted_dmgpath)
if app_path:
vers = get_os_version(app_path)
minimum_munki_version = '3.0.0.3211'
minimum_os_version = '10.8'
if vers:
display_name = os.path.splitext(os.path.basename(app_path))[0]
name = display_name.replace(' ', '_')
description = 'Installs macOS version %s' % vers
if vers.startswith('10.12'):
# Sierra was 8.8GB at http://www.apple.com/macos/how-to-upgrade/
# (http://web.archive.org/web/20160910163424/
# https://www.apple.com/macos/how-to-upgrade/)
installed_size = int(8.8 * 1024 * 1024)
elif vers.startswith('10.13'):
# High Sierra:
# "14.3GB of available storage to perform upgrade"
# http://www.apple.com/macos/how-to-upgrade/
installed_size = int(14.3 * 1024 * 1024)
elif vers.startswith('10.14'):
# Mojave:
# "12.5GB of available storage space, or up to 18.5GB of
# storage space when upgrading from OS X Yosemite or earlier."
# https://support.apple.com/en-us/HT210190
installed_size = int(18.5 * 1024 * 1024)
elif vers.startswith('10.15'):
# Catalina:
# "12.5GB of available storage space, or up to 18.5GB of
# storage space when upgrading from OS X Yosemite or earlier."
# https://support.apple.com/en-us/HT201475
installed_size = int(18.5 * 1024 * 1024)
minimum_munki_version = '3.6.3'
minimum_os_version = '10.9'
elif vers.startswith('11.0'):
# No idea yet what space will be needed for Big Sur; this will
# need to be updated
installed_size = int(18.5 * 1024 * 1024)
# but we really need Munki 5.1 in place before we install
minimum_munki_version = '5.1.0'
minimum_os_version = '10.9'
else:
# will need to modify for future macOS releases, but should
# never be less than the highest version we know about
installed_size = int(18.5 * 1024 * 1024)
minimum_munki_version = '5.1.0'
minimum_os_version = '10.9'
return {'RestartAction': 'RequireRestart',
'apple_item': True,
'description': description,
'display_name': display_name,
'installed_size': installed_size,
'installer_type': 'startosinstall',
'minimum_munki_version': minimum_munki_version,
'minimum_os_version': minimum_os_version,
'name': name,
'uninstallable': False,
'version': vers}
return None
def startosinstall(installer, finishing_tasks=None, installinfo=None):
'''Run startosinstall to set up an install of macOS, using a Install app
installed locally or located on a given disk image. Returns True if
startosinstall completes successfully, False otherwise.'''
try:
StartOSInstallRunner(
installer,
finishing_tasks=finishing_tasks, installinfo=installinfo).start()
return True
except StartOSInstallError as err:
display.display_error(
u'Error starting macOS install: %s', err)
munkilog.log(
u'Starting macOS install: FAILED: %s' % err, 'Install.log')
return False
def run(finishing_tasks=None):
'''Runs the first startosinstall item in InstallInfo.plist's
managed_installs. Returns True if successful, False otherwise'''
managedinstallbase = prefs.pref('ManagedInstallDir')
cachedir = os.path.join(managedinstallbase, 'Cache')
installinfopath = os.path.join(managedinstallbase, 'InstallInfo.plist')
try:
installinfo = FoundationPlist.readPlist(installinfopath)
except FoundationPlist.NSPropertyListSerializationException:
display.display_error("Invalid %s" % installinfopath)
return False
if prefs.pref('SuppressStopButtonOnInstall'):
munkistatus.hideStopButton()
success = False
if "managed_installs" in installinfo:
if not processes.stop_requested():
# filter list to items that need to be installed
installlist = [
item for item in installinfo['managed_installs']
if item.get('installer_type') == 'startosinstall']
if installlist:
munkilog.log("### Beginning os installer session ###")
item = installlist[0]
if not 'installer_item' in item:
display.display_error(
'startosinstall item is missing installer_item.')
return False
display.display_status_major('Starting macOS upgrade...')
# set indeterminate progress bar
munkistatus.percent(-1)
# remove the InstallInfo.plist since it won't be valid
# after the upgrade
try:
os.unlink(installinfopath)
except (OSError, IOError):
pass
itempath = os.path.join(cachedir, item["installer_item"])
success = startosinstall(
itempath,
finishing_tasks=finishing_tasks, installinfo=item)
munkilog.log("### Ending os installer session ###")
return success
if __name__ == '__main__':
print('This is a library of support tools for the Munki Suite.')