mirror of
https://github.com/munki/munki.git
synced 2026-02-22 06:59:09 -06:00
315 lines
11 KiB
Python
315 lines
11 KiB
Python
# encoding: utf-8
|
|
#
|
|
# Copyright 2009-2019 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.
|
|
"""
|
|
installer.pkg
|
|
|
|
Created by Greg Neagle on 2017-01-03.
|
|
|
|
Routines for installing Apple pkgs
|
|
"""
|
|
from __future__ import absolute_import, print_function
|
|
|
|
import os
|
|
import pwd
|
|
import subprocess
|
|
import time
|
|
|
|
from .. import display
|
|
from .. import dmgutils
|
|
from .. import launchd
|
|
from .. import munkilog
|
|
from .. import munkistatus
|
|
from .. import osutils
|
|
from .. import processes
|
|
from .. import pkgutils
|
|
from .. import FoundationPlist
|
|
|
|
|
|
def remove_bundle_relocation_info(pkgpath):
|
|
'''Attempts to remove any info in the package that would cause
|
|
bundle relocation behavior. This makes bundles install or update in their
|
|
default location.'''
|
|
display.display_debug1("Looking for bundle relocation info...")
|
|
if os.path.isdir(pkgpath):
|
|
# remove relocatable stuff
|
|
tokendefinitions = os.path.join(
|
|
pkgpath, "Contents/Resources/TokenDefinitions.plist")
|
|
if os.path.exists(tokendefinitions):
|
|
try:
|
|
os.remove(tokendefinitions)
|
|
display.display_debug1(
|
|
"Removed Contents/Resources/TokenDefinitions.plist")
|
|
except OSError:
|
|
pass
|
|
|
|
plist = {}
|
|
infoplist = os.path.join(pkgpath, "Contents/Info.plist")
|
|
if os.path.exists(infoplist):
|
|
try:
|
|
plist = FoundationPlist.readPlist(infoplist)
|
|
except FoundationPlist.NSPropertyListSerializationException:
|
|
pass
|
|
|
|
if 'IFPkgPathMappings' in plist:
|
|
del plist['IFPkgPathMappings']
|
|
try:
|
|
FoundationPlist.writePlist(plist, infoplist)
|
|
display.display_debug1("Removed IFPkgPathMappings")
|
|
except FoundationPlist.NSPropertyListWriteException:
|
|
pass
|
|
|
|
|
|
def pkg_needs_restart(pkgpath, options):
|
|
'''Query a package for its RestartAction. Returns True if a restart is
|
|
needed, False otherwise'''
|
|
cmd = ['/usr/sbin/installer', '-query', 'RestartAction', '-pkg', pkgpath]
|
|
if options.get('installer_choices_xml'):
|
|
choices_xml_file = os.path.join(osutils.tmpdir(), 'choices.xml')
|
|
FoundationPlist.writePlist(
|
|
options.get('installer_choices_xml'), choices_xml_file)
|
|
cmd.extend(['-applyChoiceChangesXML', choices_xml_file])
|
|
else:
|
|
choices_xml_file = None
|
|
if options.get('allow_untrusted'):
|
|
cmd.append('-allowUntrusted')
|
|
proc = subprocess.Popen(cmd, shell=False, bufsize=-1,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
(output, dummy_err) = proc.communicate()
|
|
restartaction = str(output).decode('UTF-8').rstrip('\n')
|
|
return (restartaction == 'RequireRestart' or
|
|
restartaction == 'RecommendRestart')
|
|
|
|
|
|
def get_installer_env(custom_env):
|
|
'''Sets up environment for installer'''
|
|
env_vars = os.environ.copy()
|
|
# get info for root
|
|
userinfo = pwd.getpwuid(0)
|
|
env_vars['USER'] = userinfo.pw_name
|
|
env_vars['HOME'] = userinfo.pw_dir
|
|
if custom_env:
|
|
# Munki admin has specified custom installer environment
|
|
for key in custom_env.keys():
|
|
if key == 'USER' and custom_env[key] == 'CURRENT_CONSOLE_USER':
|
|
# current console user (if there is one) 'owns' /dev/console
|
|
userinfo = pwd.getpwuid(os.stat('/dev/console').st_uid)
|
|
env_vars['USER'] = userinfo.pw_name
|
|
env_vars['HOME'] = userinfo.pw_dir
|
|
else:
|
|
env_vars[key] = custom_env[key]
|
|
display.display_debug1(
|
|
'Using custom installer environment variables: %s', env_vars)
|
|
return env_vars
|
|
|
|
|
|
def _display_installer_output(installinfo):
|
|
'''Parses a line of output from installer, displays it as progress output
|
|
and logs it'''
|
|
# output we're dealing with always starts with 'installer:'
|
|
msg = installinfo[10:].rstrip("\n")
|
|
if msg.startswith("PHASE:"):
|
|
phase = msg[6:]
|
|
if phase:
|
|
display.display_status_minor(phase)
|
|
elif msg.startswith("STATUS:"):
|
|
status = msg[7:]
|
|
if status:
|
|
display.display_status_minor(status)
|
|
elif msg.startswith("%"):
|
|
percent = float(msg[1:])
|
|
munkistatus.percent(percent)
|
|
display.display_status_minor("%s percent complete" % percent)
|
|
elif msg.startswith(" Error"):
|
|
display.display_error(msg)
|
|
munkistatus.detail(msg)
|
|
elif msg.startswith(" Cannot install"):
|
|
display.display_error(msg)
|
|
munkistatus.detail(msg)
|
|
else:
|
|
munkilog.log(msg)
|
|
|
|
|
|
def _run_installer(cmd, env_vars, packagename):
|
|
'''Runs /usr/sbin/installer, parses and displays the output, and returns
|
|
the process exit code'''
|
|
installeroutput = []
|
|
try:
|
|
job = launchd.Job(cmd, environment_vars=env_vars)
|
|
job.start()
|
|
except launchd.LaunchdJobException as err:
|
|
display.display_error(
|
|
'Error with launchd job (%s): %s', cmd, str(err))
|
|
display.display_error('Can\'t run installer.')
|
|
return -3 # arbitrary non-zero exit
|
|
|
|
timeout = 2 * 60 * 60
|
|
inactive = 0
|
|
last_output = None
|
|
while True:
|
|
installinfo = job.stdout.readline()
|
|
if not installinfo:
|
|
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 this installer session
|
|
display.display_error(
|
|
"/usr/sbin/installer 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
|
|
|
|
# Don't bother parsing the stdout output if it hasn't changed since
|
|
# the last loop iteration.
|
|
if last_output == installinfo:
|
|
continue
|
|
last_output = installinfo
|
|
|
|
installinfo = installinfo.decode('UTF-8')
|
|
if installinfo.startswith("installer:"):
|
|
# save all installer output in case there is
|
|
# an error so we can dump it to the log
|
|
installeroutput.append(installinfo)
|
|
_display_installer_output(installinfo)
|
|
|
|
# installer exited
|
|
retcode = job.returncode()
|
|
if retcode != 0:
|
|
# append stdout to our installer output
|
|
installeroutput.extend(job.stderr.read().splitlines())
|
|
display.display_status_minor(
|
|
"Install of %s failed with return code %s" % (packagename, retcode))
|
|
display.display_error("-"*78)
|
|
for line in installeroutput:
|
|
display.display_error(line.rstrip("\n"))
|
|
display.display_error("-"*78)
|
|
elif retcode == 0:
|
|
munkilog.log("Install of %s was successful." % packagename)
|
|
munkistatus.percent(100)
|
|
return retcode
|
|
|
|
|
|
def install(pkgpath, options=None):
|
|
"""
|
|
Uses the apple installer to install the package or metapackage
|
|
at pkgpath.
|
|
Returns a tuple:
|
|
the installer return code and restart needed as a boolean.
|
|
"""
|
|
|
|
restartneeded = False
|
|
|
|
if not options:
|
|
options = {}
|
|
display_name = options.get('display_name') or options.get('name')
|
|
|
|
if os.path.islink(pkgpath):
|
|
# resolve links before passing them to /usr/bin/installer
|
|
pkgpath = os.path.realpath(pkgpath)
|
|
|
|
if options.get('suppress_bundle_relocation'):
|
|
remove_bundle_relocation_info(pkgpath)
|
|
|
|
packagename = os.path.basename(pkgpath)
|
|
if not display_name:
|
|
display_name = packagename
|
|
|
|
munkilog.log("Installing %s from %s" % (display_name, packagename))
|
|
if pkg_needs_restart(pkgpath, options):
|
|
display.display_status_minor(
|
|
'%s requires a restart after installation.' % display_name)
|
|
restartneeded = True
|
|
|
|
# set up installer cmd
|
|
cmd = ['/usr/sbin/installer', '-verboseR', '-pkg', pkgpath, '-target', '/']
|
|
if options.get('installer_choices_xml'):
|
|
# choices_xml_file was already built by pkg_needs_restart(),
|
|
# just re-use it
|
|
choices_xml_file = os.path.join(osutils.tmpdir(), 'choices.xml')
|
|
cmd.extend(['-applyChoiceChangesXML', choices_xml_file])
|
|
if options.get('allow_untrusted'):
|
|
cmd.append('-allowUntrusted')
|
|
|
|
# get env for installer
|
|
env_vars = get_installer_env(options.get('installer_environment'))
|
|
|
|
# run it!
|
|
retcode = _run_installer(cmd, env_vars, packagename)
|
|
if retcode:
|
|
restartneeded = False
|
|
return (retcode, restartneeded)
|
|
|
|
|
|
def installall(dirpath, options=None):
|
|
"""
|
|
Attempts to install all pkgs and mpkgs in a given directory.
|
|
Will mount dmg files and install pkgs and mpkgs found at the
|
|
root of any mountpoints.
|
|
"""
|
|
retcode = 0
|
|
restartflag = False
|
|
installitems = osutils.listdir(dirpath)
|
|
for item in installitems:
|
|
if processes.stop_requested():
|
|
return (retcode, restartflag)
|
|
itempath = os.path.join(dirpath, item)
|
|
if pkgutils.hasValidDiskImageExt(item):
|
|
display.display_info("Mounting disk image %s" % item)
|
|
mountpoints = dmgutils.mountdmg(itempath, use_shadow=True)
|
|
if mountpoints == []:
|
|
display.display_error("No filesystems mounted from %s", item)
|
|
return (retcode, restartflag)
|
|
if processes.stop_requested():
|
|
dmgutils.unmountdmg(mountpoints[0])
|
|
return (retcode, restartflag)
|
|
for mountpoint in mountpoints:
|
|
# install all the pkgs and mpkgs at the root
|
|
# of the mountpoint -- call us recursively!
|
|
(retcode, needsrestart) = installall(
|
|
mountpoint, options=options)
|
|
if needsrestart:
|
|
restartflag = True
|
|
if retcode:
|
|
# ran into error; should unmount and stop.
|
|
dmgutils.unmountdmg(mountpoints[0])
|
|
return (retcode, restartflag)
|
|
|
|
dmgutils.unmountdmg(mountpoints[0])
|
|
|
|
if pkgutils.hasValidInstallerItemExt(item):
|
|
(retcode, needsrestart) = install(
|
|
itempath, options=options)
|
|
if needsrestart:
|
|
restartflag = True
|
|
if retcode:
|
|
# ran into error; should stop.
|
|
return (retcode, restartflag)
|
|
|
|
return (retcode, restartflag)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print('This is a library of support tools for the Munki Suite.')
|