#!/usr/bin/python # encoding: utf-8 # # Copyright 2009 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 distutils import version import subprocess import hashlib 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 p = subprocess.Popen(['/usr/bin/hdiutil', 'imageinfo', dmgpath, '-plist'], bufsize=1, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (plist, err) = p.communicate() if err: print >>sys.stderr, "hdiutil error %s with image %s." % (err, dmgpath) if plist: pl = FoundationPlist.readPlistFromString(plist) properties = pl.get('Properties') if properties: hasSLA = properties.get('Software License Agreement',False) return hasSLA def getCatalogInfoFromDmg(dmgpath, pkgname='', appname=''): """ * 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 pkgname: pkgpath = os.path.join(mountpoints[0], pkgname) if os.path.exists(pkgpath): cataloginfo = munkicommon.getPackageMetaData(pkgpath) if cataloginfo: cataloginfo['package_path'] = pkgname elif not appname: # search for first package at root for fsitem in os.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 not cataloginfo: cataloginfo = adobeutils.getAdobeCatalogInfo(mountpoints[0], pkgname) if not cataloginfo: # maybe this is an appdmg # look for an app at the top level of the dmg appinfo = {} if appname: itempath = os.path.join(mountpoints[0], appname) if munkicommon.isApplication(itempath): appinfo = getiteminfo(itempath) else: appname = '' for item in os.listdir(mountpoints[0]): itempath = os.path.join(mountpoints[0], item) if munkicommon.isApplication(itempath): appname = item appinfo = getiteminfo(itempath) if appinfo: break if appinfo: appinfo['path'] = os.path.join("/Applications", appname) cataloginfo = {} cataloginfo['name'] = appinfo.get('CFBundleName', os.path.splitext(appname)[0]) cataloginfo['version'] = \ munkicommon.padVersionString( appinfo.get('CFBundleShortVersionString', "0") ,5) cataloginfo['installs'] = [appinfo] cataloginfo['installer_type'] = "appdmg" cataloginfo['uninstallable'] = True cataloginfo['uninstall_method'] = "remove_app" #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: pl = FoundationPlist.readPlist(infopath) return pl except FoundationPlist.NSPropertyListSerializationException: pass return None def getmd5hash(filename): """ Returns hex of MD5 checksum of a file """ if not os.path.isfile(filename): return "NOT A FILE" f = open(filename, 'rb') m = hashlib.md5() while 1: chunk = f.read(2**16) if not chunk: break m.update(chunk) f.close() return m.hexdigest() 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 pl = getBundleInfo(itempath) if 'CFBundleName' in pl: infodict['CFBundleName'] = pl['CFBundleName'] if 'CFBundleIdentifier' in pl: infodict['CFBundleIdentifier'] = pl['CFBundleIdentifier'] infodict['CFBundleShortVersionString'] = \ munkicommon.getVersionString(pl) if 'LSMinimumSystemVersion' in pl: infodict['minosversion'] = pl['LSMinimumSystemVersion'] elif 'SystemVersionCheck:MinimumSystemVersion' in pl: infodict['minosversion'] = \ pl['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 pl = getBundleInfo(itempath) infodict['CFBundleShortVersionString'] = \ munkicommon.getVersionString(pl) elif itempath.endswith("Info.plist") or \ itempath.endswith("version.plist"): infodict['type'] = 'plist' infodict['path'] = itempath try: pl = FoundationPlist.readPlist(itempath) infodict['CFBundleShortVersionString'] = \ munkicommon.getVersionString(pl) 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'] = getmd5hash(itempath) return infodict def main(): usage = "usage: %prog [options] [/path/to/installeritem]" 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. 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, this 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('--appname', '-a', help='''Optional flag. -If the installer item is a disk image with a drag-and-drop application, this is the name or relative path of the application to be installed. Useful if there is more than one application at the root of the dmg.''') p.add_option('--uninstallerdmg', '-u', help='''If installer item is a DMG 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.''') options, arguments = p.parse_args() if len(arguments) == 0 and not options.file: print >>sys.stderr, \ ("Need to specify an installer item (.pkg, .mpkg, .dmg) " "and/or --file options!") 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 if os.path.isfile(item): itemsize = int(os.path.getsize(item)) if os.path.isdir(item): # need to walk the dir and add it all up for (path, dirs, files) in os.walk(item): for f in files: filename = os.path.join(path, f) # use os.lstat so we don't follow symlinks itemsize += int(os.lstat(filename).st_size) if item.endswith('.dmg'): pkgname = '' appname = '' if options.pkgname: pkgname = options.pkgname if options.appname: appname = options.appname catinfo = getCatalogInfoFromDmg(item, pkgname, appname) 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) else: print >>sys.stderr, "%s is not an installer package!" % item exit(-1) catinfo['installer_item_size'] = int(itemsize/1024) # 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)) catinfo['uninstaller_item_size'] = int(itemsize/1024) else: print >>sys.stderr, "No uninstaller at %s" % \ uninstallerpath # some metainfo catinfo['catalogs'] = ['testing'] if catinfo.get('receipts',None): catinfo['uninstallable'] = True catinfo['uninstall_method'] = "removepackages" minosversion = "" maxfileversion = "0.0.0.0.0" 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 version.LooseVersion(thisminosversion) < \ version.LooseVersion(minosversion): minosversion = thisminosversion if 'CFBundleShortVersionString' in iteminfodict: thisitemversion = \ munkicommon.padVersionString( iteminfodict['CFBundleShortVersionString'],5) if version.LooseVersion(thisitemversion) > \ version.LooseVersion(maxfileversion): maxfileversion = thisitemversion installs.append(iteminfodict) else: print >>sys.stderr, "Item %s doesn't exist. Skipping." % fitem 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 installs: catinfo['installs'] = installs # and now, what we've all been waiting for... print FoundationPlist.writePlistToString(catinfo) if __name__ == '__main__': main()