mirror of
https://github.com/munki/munki.git
synced 2026-06-06 20:08:09 +00:00
1058 lines
41 KiB
Python
1058 lines
41 KiB
Python
# encoding: utf-8
|
|
#
|
|
# Copyright 2017-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.
|
|
"""
|
|
osinstaller.py
|
|
|
|
Created by Greg Neagle on 2017-03-29.
|
|
|
|
Support for using startosinstall to install macOS.
|
|
August 2022: added support for launching the Install macOS app
|
|
"""
|
|
# 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
|
|
# pylint: disable=useless-object-inheritance
|
|
|
|
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,E0401
|
|
from Foundation import CFPreferencesSetValue
|
|
from Foundation import kCFPreferencesAnyUser
|
|
from Foundation import kCFPreferencesCurrentHost
|
|
# pylint: enable=E0611,E0401
|
|
|
|
# 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 info
|
|
from . import launchd
|
|
from . import munkilog
|
|
from . import munkistatus
|
|
from . import osutils
|
|
from . import pkgutils
|
|
from . import prefs
|
|
from . import processes
|
|
from . import reports
|
|
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_info_from_app(app_path):
|
|
'''Returns info parsed out of OS Installer app'''
|
|
app_info = {}
|
|
installinfo_plist = os.path.join(
|
|
app_path, 'Contents/SharedSupport/InstallInfo.plist')
|
|
if os.path.isfile(installinfo_plist):
|
|
try:
|
|
install_info = FoundationPlist.readPlist(installinfo_plist)
|
|
app_info['version'] = install_info['System Image Info']['version']
|
|
except (FoundationPlist.FoundationPlistException,
|
|
IOError, KeyError, AttributeError, TypeError):
|
|
app_info['version'] = ''
|
|
return app_info
|
|
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:
|
|
plist_path = os.path.join(
|
|
mountpoints[0],
|
|
"com_apple_MobileAsset_MacSoftwareUpdate",
|
|
"com_apple_MobileAsset_MacSoftwareUpdate.xml"
|
|
)
|
|
try:
|
|
plist = FoundationPlist.readPlist(plist_path)
|
|
app_info['version'] = plist['Assets'][0]['OSVersion']
|
|
models = []
|
|
for asset in plist.get("Assets", []):
|
|
models.extend(asset.get('SupportedDeviceModels', []))
|
|
app_info['SupportedDeviceModels'] = models
|
|
except (FoundationPlist.FoundationPlistException, KeyError, AttributeError):
|
|
app_info['version'] = ''
|
|
finally:
|
|
dmgutils.unmountdmg(mountpoints[0])
|
|
return app_info
|
|
|
|
|
|
def generate_installable_condition(models):
|
|
"""Generates an NSPredicate expression to be used as an installable
|
|
condition limiting the hardware models this item is applicable for"""
|
|
board_ids = ['"' + item + '"' for item in models if item.startswith('Mac-')]
|
|
if board_ids:
|
|
board_id_list = ", ".join(board_ids)
|
|
board_id_predicate = "board_id IN {%s}" % board_id_list
|
|
else:
|
|
board_id_predicate = ""
|
|
|
|
device_ids = ['"' + item + '"' for item in models
|
|
if not item.startswith('Mac-')]
|
|
if device_ids:
|
|
device_id_list = ", ".join(device_ids)
|
|
device_id_predicate = "device_id IN {%s}" % device_id_list
|
|
else:
|
|
device_id_predicate = ""
|
|
|
|
predicate = ""
|
|
if board_id_predicate and device_id_predicate:
|
|
predicate = board_id_predicate + " OR " + device_id_predicate
|
|
elif board_id_predicate:
|
|
predicate = board_id_predicate
|
|
elif device_id_predicate:
|
|
predicate = device_id_predicate
|
|
return predicate
|
|
|
|
|
|
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')
|
|
# 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.
|
|
# (but it's still there in the macOS 13 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'''
|
|
|
|
|
|
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 info.is_apple_silicon():
|
|
raise StartOSInstallError(
|
|
'Skipping macOS upgrade because this is not currently '
|
|
'supported on Apple silicon.')
|
|
|
|
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')
|
|
|
|
app_info = get_info_from_app(app_path)
|
|
os_vers_to_install = app_info.get('version')
|
|
if not os_vers_to_install:
|
|
display.display_warning(
|
|
'Could not get OS version to install from application bundle.')
|
|
|
|
# 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 os_vers_to_install and 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 os_vers_to_install and 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 os_vers_to_install and 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) from 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()
|
|
|
|
if 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')
|
|
if retcode:
|
|
# 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())
|
|
elif retcode:
|
|
# did not get SIGUR1 and exited non-zero
|
|
# 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. '
|
|
'See /var/log/install.log for details.' % retcode)
|
|
else:
|
|
# retcode == 0 but we got no SIGUSR1
|
|
raise StartOSInstallError(
|
|
'startosinstall did not complete successfully. '
|
|
'See /var/log/install.log for details.')
|
|
|
|
|
|
def get_startosinstall_catalog_info(mounted_dmgpath):
|
|
'''Returns catalog info (pkginfo) for a macOS installer on a disk
|
|
image, using the startosinstall installation method'''
|
|
app_path = find_install_macos_app(mounted_dmgpath)
|
|
if app_path:
|
|
app_info = get_info_from_app(app_path)
|
|
vers = app_info.get('version')
|
|
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:
|
|
# https://support.apple.com/en-us/HT210190
|
|
installed_size = int(18.5 * 1024 * 1024)
|
|
elif vers.startswith('10.15'):
|
|
# Catalina:
|
|
# 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.'):
|
|
# Big Sur
|
|
# https://support.apple.com/en-us/HT211238
|
|
installed_size = int(35.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'
|
|
elif vers.startswith('12.'):
|
|
# Monterey
|
|
# https://support.apple.com/en-us/HT212551
|
|
installed_size = int(26 * 1024 * 1024)
|
|
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(26 * 1024 * 1024)
|
|
minimum_munki_version = '5.1.0'
|
|
minimum_os_version = '10.9'
|
|
|
|
catalog_info = {
|
|
'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,
|
|
'supported_architectures': ["x86_64"],
|
|
'uninstallable': False,
|
|
'version': vers
|
|
}
|
|
|
|
models = app_info.get('SupportedDeviceModels')
|
|
if models:
|
|
catalog_info['installable_condition_disabled'] = \
|
|
generate_installable_condition(models)
|
|
|
|
return catalog_info
|
|
|
|
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
|
|
|
|
|
|
#### support for launching Install macOS app ####
|
|
|
|
##### functions for working with info about a staged macOS installer #####
|
|
|
|
def get_stage_os_installer_catalog_info(app_path):
|
|
'''Returns additional catalog info from macOS installer at app_path,
|
|
describing a stage_os_installer item'''
|
|
|
|
# calculate the size of the installer app
|
|
appsize = 0
|
|
for (path, _, files) in os.walk(app_path):
|
|
for name in files:
|
|
filename = os.path.join(path, name)
|
|
# use os.lstat so we don't follow symlinks
|
|
appsize += int(os.lstat(filename).st_size)
|
|
# convert to kbytes
|
|
appsize = int(appsize/1024)
|
|
|
|
app_info = get_info_from_app(app_path)
|
|
vers = app_info.get('version')
|
|
minimum_munki_version = '6.0.0'
|
|
minimum_os_version = '10.9'
|
|
if vers:
|
|
display_name_staged = os.path.splitext(os.path.basename(app_path))[0]
|
|
macos_name = display_name_staged.replace('Install ', '')
|
|
display_name = '%s Installer' % macos_name
|
|
description = 'Downloads %s installer' % macos_name
|
|
description_staged = 'Installs %s, version %s' % (macos_name, vers)
|
|
if vers.startswith('11.'):
|
|
# Big Sur requires 35.5GB of available storage to upgrade.
|
|
# https://support.apple.com/en-us/HT211238
|
|
installed_size = int(35.5 * 1024 * 1024) - appsize
|
|
elif vers.startswith('12.'):
|
|
# Monterey requires 26GB of available storage to upgrade.
|
|
# https://support.apple.com/en-us/HT212551
|
|
installed_size = int(26 * 1024 * 1024) - appsize
|
|
else:
|
|
# will need to modify for future macOS releases, but should
|
|
# never be less than the highest version we know about
|
|
installed_size = int(26 * 1024 * 1024) - appsize
|
|
|
|
catalog_info = {
|
|
'description': description,
|
|
'description_staged': description_staged,
|
|
'display_name': display_name,
|
|
'display_name_staged': display_name_staged,
|
|
'installed_size_staged': installed_size,
|
|
'installer_type': 'stage_os_installer',
|
|
'minimum_munki_version': minimum_munki_version,
|
|
'minimum_os_version': minimum_os_version,
|
|
'name': display_name_staged.replace(' ', '_'),
|
|
'uninstallable': True,
|
|
'version': vers
|
|
}
|
|
|
|
models = app_info.get('SupportedDeviceModels')
|
|
if models:
|
|
catalog_info['installable_condition_disabled'] = generate_installable_condition(models)
|
|
|
|
return catalog_info
|
|
|
|
return {}
|
|
|
|
|
|
def verify_staged_os_installer(app_path):
|
|
'''Attempts to trigger a "verification" process against the staged macOS
|
|
installer. This improves the launch time.'''
|
|
display.display_status_minor("Verifying macOS installer...")
|
|
display.display_percent_done(-1, 100)
|
|
startosinstall_path = os.path.join(
|
|
app_path, 'Contents/Resources/startosinstall')
|
|
try:
|
|
proc = subprocess.Popen([startosinstall_path, "--usage"],
|
|
shell=False,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
except (OSError, IOError) as err:
|
|
display.display_warning(u'Error verifying macOS installer: %s', err)
|
|
else:
|
|
stderr = proc.communicate()[1]
|
|
if proc.returncode:
|
|
display.display_warning(u'Error verifying macOS installer: %s', stderr)
|
|
|
|
|
|
def staged_os_installer_info_path():
|
|
'''returns the path to the StagedOSInstaller.plist (which may or may not
|
|
actually exist)'''
|
|
managedinstallbase = prefs.pref('ManagedInstallDir')
|
|
return os.path.join(managedinstallbase, 'StagedOSInstaller.plist')
|
|
|
|
|
|
def get_osinstaller_path(iteminfo):
|
|
'''Returns the expected path to the locally staged macOS installer'''
|
|
try:
|
|
copied_item = iteminfo["items_to_copy"][0]
|
|
except (KeyError, IndexError):
|
|
return None
|
|
source_itemname = copied_item.get("source_item")
|
|
destpath = copied_item.get('destination_path')
|
|
dest_itemname = copied_item.get("destination_item")
|
|
if not destpath:
|
|
destpath = copied_item.get('destination_item')
|
|
if destpath:
|
|
# split it into path and name
|
|
dest_itemname = os.path.basename(destpath)
|
|
destpath = os.path.dirname(destpath)
|
|
if not destpath:
|
|
return None
|
|
return os.path.join(
|
|
destpath, os.path.basename(dest_itemname or source_itemname))
|
|
|
|
|
|
def create_osinstaller_info(iteminfo):
|
|
'''Creates a dict describing a staged OS installer'''
|
|
osinstaller_info = {}
|
|
osinstaller_path = get_osinstaller_path(iteminfo)
|
|
if osinstaller_path:
|
|
osinstaller_info['osinstaller_path'] = osinstaller_path
|
|
osinstaller_info['name'] = iteminfo.get('name', '')
|
|
osinstaller_info['display_name'] = iteminfo.get(
|
|
'display_name_staged',
|
|
iteminfo.get('display_name', iteminfo['name'])
|
|
)
|
|
osinstaller_info['description'] = iteminfo.get(
|
|
'description_staged',
|
|
iteminfo.get('description', '')
|
|
)
|
|
osinstaller_info['installed_size'] = iteminfo.get(
|
|
'installed_size_staged',
|
|
iteminfo.get('installed_size',
|
|
iteminfo.get('installer_item_size', 0))
|
|
)
|
|
osinstaller_info['installed'] = False
|
|
osinstaller_info['version_to_install'] = iteminfo.get(
|
|
'version_to_install',
|
|
iteminfo.get('version', 'UNKNOWN')
|
|
)
|
|
osinstaller_info['developer'] = iteminfo.get('developer', 'Apple')
|
|
# optional keys to copy if they exist
|
|
optional_keys = ['category', 'icon_name', 'localized_strings']
|
|
for key in optional_keys:
|
|
if key in iteminfo:
|
|
osinstaller_info[key] = iteminfo[key]
|
|
return osinstaller_info
|
|
|
|
|
|
def record_staged_os_installer(iteminfo):
|
|
'''Records info on a staged macOS installer. This includes info for
|
|
managedsoftwareupdate and Managed Software Center to display, and the
|
|
path to the staged installer.'''
|
|
infopath = staged_os_installer_info_path()
|
|
staged_os_installer_info = create_osinstaller_info(iteminfo)
|
|
if staged_os_installer_info:
|
|
try:
|
|
FoundationPlist.writePlist(staged_os_installer_info, infopath)
|
|
except FoundationPlist.FoundationPlistException as err:
|
|
display.display_error(
|
|
"Error recording staged macOS installer: %s" % err)
|
|
# finally, trigger a verification
|
|
verify_staged_os_installer(staged_os_installer_info["osinstaller_path"])
|
|
else:
|
|
display.display_error("Error recording staged macOS installer: "
|
|
"could not get osinstaller_path")
|
|
|
|
|
|
def get_staged_os_installer_info():
|
|
'''Returns any info we may have on a staged OS installer'''
|
|
infopath = staged_os_installer_info_path()
|
|
if not os.path.exists(infopath):
|
|
return None
|
|
try:
|
|
osinstaller_info = FoundationPlist.readPlist(infopath)
|
|
except FoundationPlist.NSPropertyListSerializationException:
|
|
display.display_error("Invalid %s" % infopath)
|
|
return None
|
|
app_path = osinstaller_info.get("osinstaller_path")
|
|
if not app_path or not os.path.exists(app_path):
|
|
try:
|
|
os.unlink(infopath)
|
|
except (OSError, IOError):
|
|
pass
|
|
return None
|
|
return osinstaller_info
|
|
|
|
|
|
def remove_staged_os_installer_info():
|
|
'''Removes any staged OS installer we may have'''
|
|
infopath = staged_os_installer_info_path()
|
|
try:
|
|
os.unlink(infopath)
|
|
except (OSError, IOError):
|
|
pass
|
|
|
|
|
|
def display_staged_os_installer_info():
|
|
"""Prints staged macOS installer info and updates ManagedInstallReport."""
|
|
item = get_staged_os_installer_info()
|
|
if not item:
|
|
return
|
|
display.display_info('')
|
|
reports.report['StagedOSInstaller'] = item
|
|
display.display_info(
|
|
'The following macOS upgrade is available to install:')
|
|
display.display_info(
|
|
' + %s-%s' % (
|
|
item.get('display_name', item.get('name', '')),
|
|
item.get('version_to_install', '')))
|
|
display.display_info(' *Must be manually installed')
|
|
|
|
|
|
##### functions for determining if a user is a volume owner #####
|
|
|
|
def volume_owner_uuids():
|
|
'''Returns a list of local accounts that are volume owners for /'''
|
|
cryptousers = {}
|
|
try:
|
|
output = subprocess.check_output(
|
|
["/usr/sbin/diskutil", "apfs", "listUsers", "/", "-plist"])
|
|
cryptousers = FoundationPlist.readPlistFromString(output)
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
|
|
users = cryptousers.get("Users", [])
|
|
|
|
return [
|
|
user.get("APFSCryptoUserUUID")
|
|
for user in users
|
|
if "APFSCryptoUserUUID" in user
|
|
and user.get("VolumeOwner")
|
|
and user.get("APFSCryptoUserType") == "LocalOpenDirectory"
|
|
]
|
|
|
|
|
|
def generateduid(username):
|
|
'''Returns the GeneratedUID for username, or None'''
|
|
record = {}
|
|
try:
|
|
output = subprocess.check_output(
|
|
["dscl", "-plist", ".", "read", "/Users/" + username,
|
|
"GeneratedUID"])
|
|
record = FoundationPlist.readPlistFromString(output)
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
uuid_list = record.get("dsAttrTypeStandard:GeneratedUID", [])
|
|
if uuid_list:
|
|
return uuid_list[0]
|
|
return None
|
|
|
|
|
|
def user_is_volume_owner(username):
|
|
'''Returns a boolean to indicate if the user is a volume owner of /'''
|
|
return generateduid(username) in volume_owner_uuids()
|
|
|
|
|
|
##### functions for launching staged macOS installer #####
|
|
|
|
def get_adminopen_path():
|
|
'''Writes out adminopen script to a temp file. Returns the path'''
|
|
script = """#!/bin/bash
|
|
|
|
# This script is designed to be run as root.
|
|
# It takes one argument, a path to an app to be launched.
|
|
#
|
|
# If the current console user is not a member of the admin group, the user will
|
|
# be added to to the group.
|
|
# The app will then be launched in the console user's context.
|
|
# When the app exits (or this script is killed via SIGINT or SIGTERM),
|
|
# if we had promoted the user to admin, we demote that user once again.
|
|
|
|
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
|
|
|
function fail {
|
|
echo "$@" 1>&2
|
|
exit 1
|
|
}
|
|
|
|
function demote_user {
|
|
# demote CONSOLEUSER from admin
|
|
dseditgroup -o edit -d ${CONSOLEUSER} -t user admin
|
|
}
|
|
|
|
if [ $EUID -ne 0 ]; then
|
|
fail "This script must be run as root."
|
|
fi
|
|
|
|
|
|
CONSOLEUSER=$(stat -f %Su /dev/console)
|
|
if [ "${CONSOLEUSER}" == "root" ] ; then
|
|
fail "The console user may not be root!"
|
|
fi
|
|
|
|
USER_UID=$(id -u ${CONSOLEUSER})
|
|
if [ $? -ne 0 ] ; then
|
|
# failed to get UID, bail
|
|
fail "Could not get UID for ${CONSOLEUSER}"
|
|
fi
|
|
|
|
APP=$1
|
|
if [ "${APP}" == "" ] ; then
|
|
# no application specified
|
|
fail "Need to specify an application!"
|
|
fi
|
|
|
|
# check if CONSOLEUSER is admin
|
|
dseditgroup -o checkmember -m ${CONSOLEUSER} admin > /dev/null
|
|
if [ $? -ne 0 ] ; then
|
|
# not currently admin, so promote to admin
|
|
dseditgroup -o edit -a ${CONSOLEUSER} -t user admin
|
|
# make sure we demote the user at the end or if we are interrupted
|
|
trap demote_user EXIT SIGINT SIGTERM
|
|
fi
|
|
|
|
# launch $APP as $USER_UID and wait until it exits
|
|
launchctl asuser ${USER_UID} open -W "${APP}"
|
|
"""
|
|
scriptpath = os.path.join(osutils.tmpdir(), "adminopen")
|
|
try:
|
|
with open(scriptpath, mode='wb') as fileobject:
|
|
fileobject.write(script.encode('UTF-8'))
|
|
os.chown(scriptpath, 0, 0)
|
|
os.chmod(scriptpath, int('744', 8))
|
|
except (OSError, IOError) as err:
|
|
display.display_error("Couldn't create adminopen tool: %s" % err)
|
|
return ""
|
|
return scriptpath
|
|
|
|
|
|
def launch_installer_app(app_path):
|
|
'''Runs our adminopen tool to launch the Install macOS app. adminopen is run
|
|
via launchd so we can exit after the app is launched (and the user may or
|
|
may not actually complete running it.) Returns True if we run adminopen,
|
|
False otherwise (some reasons: can't find Install app, no GUI user)
|
|
'''
|
|
# do we have a GUI user?
|
|
username = osutils.getconsoleuser()
|
|
if not username or username == u"loginwindow":
|
|
# we're at the loginwindow. Bail.
|
|
display.display_error(
|
|
u'Could not launch macOS installer: No current GUI user.')
|
|
return False
|
|
|
|
# if we're on Apple silicon -- is the user a volume owner?
|
|
if info.is_apple_silicon() and not user_is_volume_owner(username):
|
|
display.display_error(
|
|
u"Could not launch macOS installer: "
|
|
"Current GUI user is not a volume owner.")
|
|
return False
|
|
|
|
# create the adminopen tool and get its path
|
|
adminopen_path = get_adminopen_path()
|
|
if not adminopen_path:
|
|
display.display_error(
|
|
u'Error launching macOS installer: Can\'t create adminopen tool.')
|
|
return False
|
|
|
|
# make sure the Install macOS app is present
|
|
if not os.path.exists(app_path):
|
|
display.display_error(
|
|
u'Error launching macOS installer: Can\'t find %s.' % app_path)
|
|
return False
|
|
|
|
# OK, we have everything we need, let's go
|
|
display.display_status_major("Launching macOS installer...")
|
|
cmd = [adminopen_path, app_path]
|
|
try:
|
|
job = launchd.Job(cmd, 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(
|
|
'Failed to launch macOS installer due to launchd error.')
|
|
return False
|
|
|
|
# sleep a bit, then check to see if our launchd job has exited with an error
|
|
time.sleep(1)
|
|
if job.returncode():
|
|
error_msg = ""
|
|
if job.stderr:
|
|
error_msg = job.stderr.read()
|
|
display.display_error('Unexpected error: %s', error_msg)
|
|
|
|
# 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)
|
|
# return True to indicate we launched the Install macOS app
|
|
return True
|
|
|
|
|
|
def launch():
|
|
'''Attempt to launch a staged OS installer'''
|
|
osinstaller_info = get_staged_os_installer_info()
|
|
|
|
osinstaller_path = osinstaller_info.get("osinstaller_path")
|
|
if not osinstaller_path:
|
|
display.display_error(
|
|
'stagedinstaller item is missing macOS installer path.')
|
|
return False
|
|
|
|
if prefs.pref('SuppressStopButtonOnInstall'):
|
|
munkistatus.hideStopButton()
|
|
|
|
munkilog.log("### Beginning GUI launch of macOS installer ###")
|
|
success = launch_installer_app(osinstaller_path)
|
|
return success
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print('This is a library of support tools for the Munki Suite.')
|