Files
munki/code/client/munkilib/installer/pkg.py

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.')