Files
munki/code/client/makepkginfo
Greg Neagle 51f79798e1 makepkginfo now allows "naked" pre- and postuninstall_script options (that is, no package needed):
makepkginfo --preuninstall_script=/path/to/script
makepkginfo --postuninstall_script=/path/to/script

git-svn-id: http://munki.googlecode.com/svn/trunk@1199 a4e17f2e-e282-11dd-95e1-755cbddbdd66
2011-05-25 17:09:18 +00:00

642 lines
27 KiB
Python
Executable File

#!/usr/bin/python
# encoding: utf-8
#
# Copyright 2009-2011 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
#
# http://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.
"""
makepkginfo
Created by Greg Neagle on 2008-11-25.
Creates a managed install pkg info plist given an Installer item:
a .pkg, a .mpkg, or a .dmg containing a .pkg or .mpkg
at the root of the mounted disk image.
You may also pass additional items that are installed by the package. These
are added to the 'installs' key of the catalog item plist and are used when
processing the catalog to check if the package needs to be installed or
reinstalled.
The generated plist is printed to STDOUT.
Usage: makepkginfo /path/to/package_or_dmg [-f /path/to/item/it/installs ...]
"""
import sys
import os
import re
import optparse
from optparse import OptionValueError
#from distutils import version
import subprocess
from munkilib import munkicommon
from munkilib import FoundationPlist
from munkilib import adobeutils
def DMGhasSLA(dmgpath):
'''Returns true if dmg has a Software License Agreement.
These dmgs cannot be attached without user intervention'''
hasSLA = False
proc = subprocess.Popen(
['/usr/bin/hdiutil', 'imageinfo', dmgpath, '-plist'],
bufsize=-1, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(pliststr, err) = proc.communicate()
if err:
print >> sys.stderr, ("hdiutil error %s with image %s."
% (err, dmgpath))
if pliststr:
try:
plist = FoundationPlist.readPlistFromString(pliststr)
properties = plist.get('Properties')
if properties:
hasSLA = properties.get('Software License Agreement', False)
except FoundationPlist.NSPropertyListSerializationException:
pass
return hasSLA
def getCatalogInfoFromDmg(dmgpath, options):
"""
* Mounts a disk image
* Gets catalog info for the first installer item found at the root level.
* Unmounts the disk image
To-do: handle multiple installer items on a disk image(?)
"""
if DMGhasSLA(dmgpath):
print >> sys.stderr, \
"%s has an attached Software License Agreement." % dmgpath
print >> sys.stderr, \
"It cannot be automatically mounted. You'll need to create a new dmg."
exit(-1)
cataloginfo = None
mountpoints = munkicommon.mountdmg(dmgpath)
if not mountpoints:
print >> sys.stderr, "Could not mount %s!" % dmgpath
exit(-1)
if options.pkgname:
pkgpath = os.path.join(mountpoints[0], options.pkgname)
if os.path.exists(pkgpath):
cataloginfo = munkicommon.getPackageMetaData(pkgpath)
if cataloginfo:
cataloginfo['package_path'] = options.pkgname
elif not options.item:
# search for first package at root
for fsitem in munkicommon.listdir(mountpoints[0]):
itempath = os.path.join(mountpoints[0], fsitem)
if itempath.endswith('.pkg') or itempath.endswith('.mpkg'):
cataloginfo = munkicommon.getPackageMetaData(itempath)
# get out of fsitem loop
break
if cataloginfo:
# we found a package, but let's see if it's an Adobe CS5 install
# (AAMEE) package
if 'receipts' in cataloginfo:
try:
pkgid = cataloginfo['receipts'][0].get('packageid')
except IndexError:
pkgid = ""
if pkgid.startswith("com.adobe.Enterprise.install"):
# we have an Adobe CS5 install package, process
# as Adobe install
adobepkgname = cataloginfo['receipts'][0].get('filename')
cataloginfo = adobeutils.getAdobeCatalogInfo(
mountpoints[0], adobepkgname)
else:
# maybe an Adobe installer/updater/patcher?
cataloginfo = adobeutils.getAdobeCatalogInfo(mountpoints[0],
options.pkgname or '')
if not cataloginfo:
# maybe this is a drag-n-drop dmg
# look for given item or an app at the top level of the dmg
iteminfo = {}
if options.item:
item = options.item
itempath = os.path.join(mountpoints[0], item)
if os.path.exists(itempath):
iteminfo = getiteminfo(itempath)
else:
print >> sys.stderr, \
"%s not found on disk image." % item
else:
# no item specified; look for an application at root of
# mounted dmg
item = ''
for itemname in munkicommon.listdir(mountpoints[0]):
itempath = os.path.join(mountpoints[0], itemname)
if munkicommon.isApplication(itempath):
item = itemname
iteminfo = getiteminfo(itempath)
if iteminfo:
break
if iteminfo:
if options.destinationpath:
iteminfo['path'] = os.path.join(options.destinationpath,
item)
else:
iteminfo['path'] = os.path.join("/Applications", item)
cataloginfo = {}
cataloginfo['name'] = iteminfo.get('CFBundleName',
os.path.splitext(item)[0])
cataloginfo['version'] = \
iteminfo.get('CFBundleShortVersionString', "0")
cataloginfo['installs'] = [iteminfo]
if options.appdmg:
cataloginfo['installer_type'] = "appdmg"
cataloginfo['uninstallable'] = True
cataloginfo['uninstall_method'] = "remove_app"
else:
cataloginfo['installer_type'] = "copy_from_dmg"
item_to_copy = {}
item_to_copy['source_item'] = item
item_to_copy['destination_path'] = \
options.destinationpath or "/Applications"
if options.user:
item_to_copy['user'] = options.user
if options.group:
item_to_copy['group'] = options.group
if options.mode:
item_to_copy['mode'] = options.mode
cataloginfo['items_to_copy'] = [item_to_copy]
cataloginfo['uninstallable'] = True
cataloginfo['uninstall_method'] = "remove_copied_items"
#eject the dmg
munkicommon.unmountdmg(mountpoints[0])
return cataloginfo
def getBundleInfo(path):
"""
Returns Info.plist data if available
for bundle at path
"""
infopath = os.path.join(path, "Contents", "Info.plist")
if not os.path.exists(infopath):
infopath = os.path.join(path, "Resources", "Info.plist")
if os.path.exists(infopath):
try:
plist = FoundationPlist.readPlist(infopath)
return plist
except FoundationPlist.NSPropertyListSerializationException:
pass
return None
def readfile(path):
'''Reads file at path. Returns a string.'''
try:
fileobject = open(os.path.expanduser(path), mode='r', buffering=1)
data = fileobject.read()
fileobject.close()
return data
except (OSError, IOError):
print >> sys.stderr, "Couldn't read %s" % path
return ""
def getiteminfo(itempath):
"""
Gets info for filesystem items passed to makecatalog item, to be used for
the "installs" key.
Determines if the item is an application, bundle, Info.plist, or a file or
directory and gets additional metadata for later comparison.
"""
infodict = {}
if munkicommon.isApplication(itempath):
infodict['type'] = 'application'
infodict['path'] = itempath
plist = getBundleInfo(itempath)
if 'CFBundleName' in plist:
infodict['CFBundleName'] = plist['CFBundleName']
if 'CFBundleIdentifier' in plist:
infodict['CFBundleIdentifier'] = plist['CFBundleIdentifier']
infodict['CFBundleShortVersionString'] = \
munkicommon.getVersionString(plist)
if 'LSMinimumSystemVersion' in plist:
infodict['minosversion'] = plist['LSMinimumSystemVersion']
elif 'SystemVersionCheck:MinimumSystemVersion' in plist:
infodict['minosversion'] = \
plist['SystemVersionCheck:MinimumSystemVersion']
elif os.path.exists(os.path.join(itempath, 'Contents', 'Info.plist')) or \
os.path.exists(os.path.join(itempath, 'Resources', 'Info.plist')):
infodict['type'] = 'bundle'
infodict['path'] = itempath
plist = getBundleInfo(itempath)
infodict['CFBundleShortVersionString'] = \
munkicommon.getVersionString(plist)
elif itempath.endswith("Info.plist") or \
itempath.endswith("version.plist"):
infodict['type'] = 'plist'
infodict['path'] = itempath
try:
plist = FoundationPlist.readPlist(itempath)
infodict['CFBundleShortVersionString'] = \
munkicommon.getVersionString(plist)
except FoundationPlist.NSPropertyListSerializationException:
pass
if not 'CFBundleShortVersionString' in infodict and \
not 'CFBundleVersion' in infodict:
infodict['type'] = 'file'
infodict['path'] = itempath
if os.path.isfile(itempath):
infodict['md5checksum'] = munkicommon.getmd5hash(itempath)
return infodict
def check_mode(option, opt, value, parser):
'''Callback to check --mode options'''
modes = value.lower().replace(',', ' ').split()
value = None
rex = re.compile("[augo]+[=+-][rstwxXugo]+")
for mode in modes:
if rex.match(mode):
value = mode if not value else (value + "," + mode)
else:
raise OptionValueError("option %s: invalid mode: %s" %
(opt, mode))
setattr(parser.values, option.dest, value)
def main():
'''Main routine'''
usage = """usage: %prog [options] [/path/to/installeritem]
%prog --help for more information."""
p = optparse.OptionParser(usage=usage)
p.add_option('--file', '-f', action="append",
help='''Path to a filesystem item installed by this
package, typically an application. This generates an
"installs" item for the pkginfo, an item munki can
use to determine if this software has been installed.
Can be specified multiple times.''')
p.add_option('--pkgname', '-p',
help='''Optional flag.
-If the installer item is a disk image containing
multiple packages, or the package to be installed
is not at the root of the mounted disk image, PKGNAME
is a relative path from the root of the mounted
disk image to the specific package to be installed.
-If the installer item is a disk image containing
an Adobe CS4 Deployment Toolkit installation, PKGNAME
is the name of an Adobe CS4 Deployment Toolkit
installer package folder at the top level of the
mounted dmg.
If this flag is missing, the AdobeUber* files should
be at the top level of the mounted dmg.''')
p.add_option('--appdmg', action="store_true",
help='''Optional flag.
Causes makepkginfo to create a pkginfo item describing
an appdmg install instead of the newer copy_from_dmg
installer type. Meant for use with older munki
clients, as copy_from_dmg replaces appdmg in munki
0.6.0 and later.''')
p.add_option('--itemname', '-i', '--appname', '-a',
metavar='ITEM',
dest='item',
help='''Optional flag.
-If the installer item is a disk image with a
drag-and-drop item, ITEMNAME is the name or
relative path of the item to be installed.
Useful if there is more than one item at the
root of the dmg.''')
p.add_option('--displayname',
help='''Optional flag.
String display name of the package.
Note: overrides any display_name in the package itself''')
p.add_option('--description',
help='''Optional flag.
String description of the package.
Note: overrides any description in the package itself''')
p.add_option('--destinationpath', '-d',
help='''Optional flag.
If the installer item is a disk image with a
drag-and-drop item, this is the path to which
the item should be copied. Defaults to
"/Applications".''')
p.add_option('--uninstallerdmg', '-u',
help='''Optional flag.
If the installer item is a disk image containing an
Adobe CS4 Deployment Toolkit installation package or
Adobe CS3 deployment package, UNINSTALLERDMG is a path
to a disk image containing an AdobeUberUninstaller for
this item.''')
p.add_option('--postinstall_script',
metavar='SCRIPT_PATH',
help='''Optional flag.
Path to an optional postinstall script to be run after
installation of the item. The script will be read and
embedded into the pkginfo.''')
p.add_option('--preinstall_script',
metavar='SCRIPT_PATH',
help='''Optional flag.
Path to an optional preinstall script to be run before
installation of the item. The script will be read and
embedded into the pkginfo.''')
p.add_option('--postuninstall_script',
metavar='SCRIPT_PATH',
help='''Optional flag.
Path to an optional postuninstall script to be run after
removal of the item. The script will be read and
embedded into the pkginfo.''')
p.add_option('--preuninstall_script',
metavar='SCRIPT_PATH',
help='''Optional flag.
Path to an optional preuninstall script to be run before
removal of the item. The script will be read and
embedded into the pkginfo.''')
p.add_option('--uninstall_script',
metavar='SCRIPT_PATH',
help='''Optional flag.
Path to an uninstall script to be run in order to
uninstall this item. The script will be read and
embedded into the pkginfo.''')
p.add_option('--catalog', '-c', action="append",
help='''Optional flag.
Specifies in which catalog the item should appear. The
default is 'testing'. Can be specified multiple times
to add the item to multiple catalogs.''')
p.add_option('-o', '--owner',
metavar='USER',
dest='user',
help='''Optional flag.
If the installer item is a disk image used with
the copy_from_dmg installer type, this sets the
owner of the item specified by the --item flag.
The owner may be either a UID or a symbolic name.
The owner will be set recursively on the item.''')
p.add_option('-g', '--group',
metavar='GROUP',
dest='group',
help='''Optional flag.
If the installer item is a disk image used with
the copy_from_dmg installer type, this sets the
group of the item specified by the --item flag.
The group may be either a GID or a symbolic name.
The group will be set recursively on the item.''')
p.add_option('-m', '--mode',
metavar='MODE',
dest='mode',
action='callback',
type='string',
callback=check_mode,
help='''Optional flag.
If the installer item is a disk used with
the copy_from_dmg installer type, this sets the
mode of the item specified by the --item flag.
The specified mode must be in symbolic form.
See the manpage for chmod(1) for more information.
The mode is applied recursively.''')
p.add_option('--version', '-V', action='store_true',
help='Print the version of the munki tools and exit.')
options, arguments = p.parse_args()
if options.version:
print munkicommon.get_version()
exit(0)
if (len(arguments) == 0
and not options.file
and not options.preinstall_script
and not options.postinstall_script
and not options.preuninstall_script
and not options.postuninstall_script
and not options.uninstall_script):
p.print_usage()
exit(-1)
if len(arguments) > 1:
print >> sys.stderr, \
("Can process only one installer item at a time. "
"Ignoring additional installer items.")
catinfo = {}
installs = []
if arguments:
item = arguments[0].rstrip("/")
if item and os.path.exists(item):
# get size of installer item
itemsize = 0
itemhash = "N/A"
if os.path.isfile(item):
itemsize = int(os.path.getsize(item))
itemhash = munkicommon.getsha256hash(item)
if item.endswith('.dmg'):
catinfo = getCatalogInfoFromDmg(item, options)
if (catinfo and
catinfo.get('installer_type') == "AdobeCS5Installer"):
print >> sys.stderr, (
"This disk image appears to contain an Adobe CS5 "
"product install.\n"
"Please use Adobe Application Manager, Enterprise "
"Edition (AAMEE) to create an installation package "
"for this product.")
exit(-1)
if not catinfo:
print >> sys.stderr, \
"Could not find a supported installer item in %s!" % \
item
exit(-1)
elif item.endswith('.pkg') or item.endswith('.mpkg'):
catinfo = munkicommon.getPackageMetaData(item)
if not catinfo:
print >> sys.stderr, \
"%s doesn't appear to be a valid installer item!" % \
item
exit(-1)
if os.path.isdir(item):
print >> sys.stderr, (
"WARNING: %s is a bundle-style package!\n"
"To use it with munki, you should encapsulate it "
"in a disk image.\n") % item
# need to walk the dir and add it all up
for (path, unused_dirs, files) in os.walk(item):
for name in files:
filename = os.path.join(path, name)
# use os.lstat so we don't follow symlinks
itemsize += int(os.lstat(filename).st_size)
# convert to kbytes
itemsize = int(itemsize/1024)
else:
print >> sys.stderr, "%s is not an installer package!" % item
exit(-1)
if options.description:
catinfo['description'] = options.description
if options.displayname:
catinfo['display_name'] = options.displayname
catinfo['installer_item_size'] = int(itemsize/1024)
catinfo['installer_item_hash'] = itemhash
# try to generate the correct item location
temppath = item
location = ""
while len(temppath) > 4:
if temppath.endswith('/pkgs'):
location = item[len(temppath)+1:]
break
else:
temppath = os.path.dirname(temppath)
if not location:
#just the filename
location = os.path.split(item)[1]
catinfo['installer_item_location'] = location
# ADOBE STUFF - though maybe generalizable in the future?
if options.uninstallerdmg:
uninstallerpath = options.uninstallerdmg
if os.path.exists(uninstallerpath):
# try to generate the correct item location
temppath = uninstallerpath
location = ""
while len(temppath) > 4:
if temppath.endswith('/pkgs'):
location = uninstallerpath[len(temppath)+1:]
break
else:
temppath = os.path.dirname(temppath)
if not location:
#just the filename
location = os.path.split(uninstallerpath)[1]
catinfo['uninstaller_item_location'] = location
itemsize = int(os.path.getsize(uninstallerpath))
itemhash = munkicommon.getsha256hash(uninstallerpath)
catinfo['uninstaller_item_size'] = int(itemsize/1024)
catinfo['uninstaller_item_hash'] = itemhash
else:
print >> sys.stderr, "No uninstaller at %s" % \
uninstallerpath
# some metainfo
if options.catalog:
catinfo['catalogs'] = options.catalog
else:
catinfo['catalogs'] = ['testing']
if catinfo.get('receipts', None):
catinfo['uninstallable'] = True
catinfo['uninstall_method'] = "removepackages"
minosversion = ""
maxfileversion = "0.0.0.0.0"
if catinfo:
catinfo['autoremove'] = False
if minosversion:
catinfo['minimum_os_version'] = minosversion
else:
catinfo['minimum_os_version'] = "10.4.0"
if not 'version' in catinfo:
if maxfileversion != "0.0.0.0.0":
catinfo['version'] = maxfileversion
else:
catinfo['version'] = "1.0.0.0.0 (Please edit me!)"
if options.file:
for fitem in options.file:
# no trailing slashes, please.
fitem = fitem.rstrip('/')
if fitem.startswith('/Library/Receipts'):
# no receipts, please!
print >> sys.stderr, \
"Item %s appears to be a receipt. Skipping." % fitem
continue
if os.path.exists(fitem):
iteminfodict = getiteminfo(fitem)
if 'minosversion' in iteminfodict:
thisminosversion = iteminfodict.pop('minosversion')
if not minosversion:
minosversion = thisminosversion
elif (munkicommon.MunkiLooseVersion(thisminosversion) <
munkicommon.MunkiLooseVersion(minosversion)):
minosversion = thisminosversion
if 'CFBundleShortVersionString' in iteminfodict:
thisitemversion = \
iteminfodict['CFBundleShortVersionString']
if (munkicommon.MunkiLooseVersion(thisitemversion) >
munkicommon.MunkiLooseVersion(maxfileversion)):
maxfileversion = thisitemversion
installs.append(iteminfodict)
else:
print >> sys.stderr, (
"Item %s doesn't exist. Skipping." % fitem)
if installs:
catinfo['installs'] = installs
if options.postinstall_script:
scriptstring = readfile(options.postinstall_script)
if scriptstring:
catinfo['postinstall_script'] = scriptstring
if options.preinstall_script:
scriptstring = readfile(options.preinstall_script)
if scriptstring:
catinfo['preinstall_script'] = scriptstring
if options.postuninstall_script:
scriptstring = readfile(options.postuninstall_script)
if scriptstring:
catinfo['postuninstall_script'] = scriptstring
if options.preuninstall_script:
scriptstring = readfile(options.preuninstall_script)
if scriptstring:
catinfo['preuninstall_script'] = scriptstring
if options.uninstall_script:
scriptstring = readfile(options.uninstall_script)
if scriptstring:
catinfo['uninstall_script'] = scriptstring
catinfo['uninstall_method'] = 'uninstall_script'
# and now, what we've all been waiting for...
print FoundationPlist.writePlistToString(catinfo)
if __name__ == '__main__':
main()