#!/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()