Skip to content

Instantly share code, notes, and snippets.

@carlashley
Created November 27, 2020 04:11
Show Gist options
  • Save carlashley/a42d78c31fe922b722b36eac61410b98 to your computer and use it in GitHub Desktop.
Save carlashley/a42d78c31fe922b722b36eac61410b98 to your computer and use it in GitHub Desktop.
Adobe CC Munki Import
#!/usr/local/bin/python3
"""This process unzipped Adobe CC packages and imports them into munki.
No copyright. Free for use, but no support provided.
This contains some fairly specific behaviour with regards to naming conventions:
- Converts filenames to lowercase, and replaces all space characters with '-'.
- Appends 'adobe-cc.' to the start of each package filename.
- Changes 'install' and 'uninstall' to 'installer' and 'uninstaller'.
- Optionally appends a suffix to the filenames and display titles in munki.
For example, if '--suffix "CC 2021" is specified:
filename: adobe-cc.after-effects-cc-2021-installer-version.pkg
displayname: Adobe After Effects CC 2021
- Automatically extracts the correct versioning details for Adobe Acrobat DC.
- Automatically extracts the correct receipt details for Adobe Acrobat DC and
updates the specific pkginfo file with these details.
- Optionally clear all extended attributes before import to avoid packages
not installing due to quarantine attributes remaining. Requires 'sudo'
password input.
"""
from __future__ import print_function
import argparse
import xml.etree.ElementTree as ET
import os
import plistlib
import re
import shutil
import sys
import subprocess
import tempfile
from datetime import datetime
from getpass import getpass
from pathlib import Path, PurePath
from pprint import pprint # NOQA
from xml.dom import minidom
ACROBAT_ATTRIBS = ['com.adobe.acrobat.DC.viewer.app.pkg.MUI',
'com.adobe.acrobat.DC.viewer.appsupport.pkg.MUI',
'com.adobe.acrobat.DC.viewer.browser.pkg.MUI',
'com.adobe.acrobat.DC.viewer.print_automator.pkg.MUI',
'com.adobe.acrobat.DC.viewer.print_pdf_services.pkg.MUI']
ACROBAT_APP_VER_ATTRIB = 'com.adobe.acrobat.DC.viewer.app.pkg.MUI'
RENAME_PREFIX = 'adobe-cc'
CURRENT_YEAR = datetime.now().strftime('%Y')
CATALOG = 'testing'
CATEGORY = 'Creativity'
DEVELOPER = 'Adobe'
SAP_CODES = ['AEFT', #: 'After Effects',
'FLPR', #: 'Animate & Mobile Device packaging',
'AUDT', #: 'Audition',
'KBRG', #: 'Bridge',
'CHAR', #: 'Character Animator',
'ESHR', #: 'Dimension',
'DRWV', #: 'Dreamweaver',
'FRSC', #: 'Fresco', # (Windows only)
'ILST', #: 'Illustrator',
'AICY', #: 'InCopy',
'IDSN', #: 'InDesign',
'LRCC', #: 'Lightroom',
'LTRM', #: 'Lightroom Classic',
'AME', #: 'Media Encoder',
'PHSP', #: 'Photoshop',
'PRLD', #: 'Prelude',
'PPRO', #: 'Premiere Pro',
'RUSH', #: 'Premiere Rush',
'SBSTA', #: 'Substance Alchemist',
'SBSTD', #: 'Substance Designer',
'SBSTP', #: 'Substance Painter',
'SPRK'] #: 'XD',
def _read_plist(o):
"""Read Property List from file or bytes."""
result = dict()
if not isinstance(o, bytes) and o.exists():
with open(o, 'rb') as _f:
result = plistlib.load(_f)
elif isinstance(o, bytes):
result = plistlib.loads(o)
return result
def _write_plist(p, d):
"""Write Property List."""
with open(p, 'wb') as _f:
plistlib.dump(d, _f)
def _find_dmg(d):
"""Find DMG."""
result = None
if d.exists():
for _root, _, _files in os.walk(d):
for _f in _files:
_path = Path(_root) / _f
_pure_path = PurePath(_path)
if _pure_path.suffix == '.dmg':
if re.search(r'APRO\d+', str(_path)):
result = _path
break
return result
def _rename(p, suffix=None):
"""Rename"""
result = None
_basepath = PurePath(p).parent
_basename = PurePath(p).name
_basename = re.sub(r'^Adobe ', 'Adobe CC.', _basename)
_basename = _basename.replace(' ', '-')
_basename = _basename.lower()
_suffix = suffix
if suffix:
_suffix = suffix.lower().replace(' ', '-')
if '_install.pkg' in _basename:
if _suffix and _suffix not in _basename:
_basename = _basename.replace('_install.pkg', '-{}-installer.pkg'.format(_suffix))
else:
_basename = _basename.replace('_install.pkg', '-installer.pkg')
elif '_uninstall.pkg' in _basename:
if _suffix and _suffix not in _basename:
_basename = _basename.replace('_uninstall.pkg', '-{}-uninstaller.pkg'.format(_suffix))
else:
_basename = _basename.replace('_uninstall.pkg', '-uninstaller.pkg')
_basename = _basename.replace('-cc-', '-')
result = Path(_basepath) / _basename
return result
def _walk(d, suffix=None):
"""Walk directory."""
result = dict()
_path = Path(d)
_inst_regex = re.compile(r'[-_]instal\w+.pkg')
_unin_regex = re.compile(r'[-_]uninstal\w+.pkg')
if _path.exists():
_packages = {_root for _root, _, _, in os.walk(d) if PurePath(_root).suffix == '.pkg'}
for _f in _packages:
_path = Path(_f)
_pure_path = PurePath(_path)
_product_name = str(PurePath(_pure_path.name).stem)
_product_name = _product_name.replace('_Uninstall', '').replace('_Install', '').replace(' ', '_').lower()
_product_name = _product_name.replace('-uninstaller', '').replace('-installer', '')
_basepath = Path(_pure_path.parent)
_basename = Path(_pure_path.name)
if not result.get(_product_name):
result[_product_name] = {'installer': None, 'installer_rename': None,
'uninstaller': None, 'uninstaller_rename': None}
if re.search(_inst_regex, str(_basename).lower()):
result[_product_name]['installer'] = _path
if not str(_path).startswith('adobe-cc.'):
result[_product_name]['installer_rename'] = _rename(_path, suffix)
elif re.search(_unin_regex, str(_basename).lower()):
result[_product_name]['uninstaller'] = _path
if not str(_path).startswith('adobe-cc.'):
result[_product_name]['uninstaller_rename'] = _rename(_path, suffix)
if 'acrobat' in (str(_basepath).lower() or str(_basename).lower()):
result[_product_name]['acrobat_dmg'] = _find_dmg(d=_basepath)
return result
def _expand_pkg(pkg, expanddir):
"""Expands a package into a temp location."""
_cmd = ['/usr/sbin/pkgutil', '--expand', pkg, expanddir]
try:
subprocess.check_call(_cmd)
except subprocess.CalledProcessError as e:
raise e
def _acrobat_dc_receipts(xmldoc, attribs=ACROBAT_ATTRIBS):
"""Generates the receipts values that need to be added to the Acrobat DC pkginfo after import"""
result = list()
if os.path.exists(xmldoc):
_root = ET.parse(xmldoc).getroot()
_choices = [_child for _child in _root.iter() if _child.tag == 'choice']
for _choice in _choices:
for _child in _choice.iter():
_id = _child.attrib.get('id')
_ver = _child.attrib.get('version')
if _id and _ver and _id in attribs:
_receipt = {'packageid': _id,
'version': _ver}
result.append(_receipt)
return result
def _get_xml_text_element(dom_node, name):
"""Returns the text value of the first item found with the given tagname"""
result = None
subelements = dom_node.getElementsByTagName(name)
if subelements:
result = ''
for node in subelements[0].childNodes:
result += node.nodeValue
return result
def _app_version_info(xmldoc):
result = dict()
dom = minidom.parse(xmldoc)
installinfo = dom.getElementsByTagName('InstallInfo')
if installinfo:
if 'id' in list(installinfo[0].attributes.keys()):
result['packager_id'] = installinfo[0].attributes['id'].value
if 'version' in list(installinfo[0].attributes.keys()):
result['packager_version'] = installinfo[0].attributes['version'].value
result['package_name'] = _get_xml_text_element(installinfo[0], 'PackageName')
result['package_id'] = _get_xml_text_element(installinfo[0], 'PackageID')
result['products'] = []
hd_medias_elements = installinfo[0].getElementsByTagName('HDMedias')
if hd_medias_elements:
hd_media_elements = hd_medias_elements[0].getElementsByTagName('HDMedia')
if hd_media_elements:
for hd_media in hd_media_elements:
product = {}
product['hd_installer'] = True
# productVersion is the 'full' version number
# prodVersion seems to be the "customer-facing" version for
# this update
# baseVersion is the first/base version for this standalone
# product/channel/LEID,
# not really needed here so we don't copy it
for elem in [
'mediaLEID',
'prodVersion',
'productVersion',
'SAPCode',
'MediaType',
'TargetFolderName']:
product[elem] = _get_xml_text_element(hd_media, elem)
result['products'].append(product)
return result
def _make_dmg(pkg):
"""Make a DMG for importing."""
_output = pkg.replace('.pkg', '.dmg')
_cmd = ['/usr/bin/hdiutil', 'create', '-fs', 'JHFS+', '-srcfolder', pkg, _output]
try:
subprocess.check_call(_cmd)
except subprocess.CalledProcessError as e:
raise e
def _mount_dmg(dmg):
"""Mount a DMG."""
result = None
_cmd = ['/usr/bin/hdiutil', 'attach', '-plist', dmg]
_process = subprocess.Popen(_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
_p_res, _p_err = _process.communicate()
if _process.returncode == 0:
_output = _p_res
if isinstance(_output, dict):
_result = _output['system-entities']
else:
_result = _read_plist(_output)['system-entities']
for _r in _result:
result = _r.get('mount-point', None)
if result:
break
return result
def _detach_dmg(volume):
"""Detach a DMG."""
_cmd = ['/usr/bin/hdiutil', 'detach', '-quiet', volume]
try:
subprocess.check_call(_cmd)
except subprocess.CalledProcessError as e:
raise e
def _xattr(p):
"""Clear extended attributes."""
_cmd = ['/usr/bin/sudo', '-S', '/usr/bin/xattr', '-cr', p]
print('Clearing extended attributes, input password for \'sudo\'.')
_pass = '{}\n'.format(getpass(prompt='Password'))
_p = subprocess.Popen(_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
_r, _e = _p.communicate(_pass)
if _p.returncode != 0:
print('Error clearing extended attributes.\n{}'.format(_e))
sys.exit(_p.returncode)
def main():
MUNKIIMPORT_PLIST = Path.home() / 'Library/Preferences/com.googlecode.munki.munkiimport.plist'
if not MUNKIIMPORT_PLIST.exists():
MUNKIIMPORT_PLIST = Path('/Library/Preferences/com.googlecode.munki.munkiimport.plist')
if MUNKIIMPORT_PLIST.exists():
_munki_repo = _read_plist(o=MUNKIIMPORT_PLIST).get('repo_url', None)
if _munki_repo:
_munki_repo = _munki_repo.replace('file://', '')
parser = argparse.ArgumentParser()
parser.add_argument('--adobe-dir',
type=str,
dest='adobe_dir',
required=True,
metavar='[dir]',
help='The directory containing all unzipped Adobe packaged directories.')
parser.add_argument('--munki-repo',
type=str,
dest='munki_repo',
required=False,
metavar='[dir]',
default=_munki_repo,
help='Override or use a custom munki repo path.')
parser.add_argument('--munki-subdir',
type=str,
dest='munki_subdir',
required=False,
metavar='[dir]',
default='apps',
help='The munki repo sub directory to import into, for example \'app\'.')
parser.add_argument('--suffix',
type=str,
dest='suffix',
required=False,
metavar='[suffix]',
default='CC {}'.format(CURRENT_YEAR),
help='The suffix to append to the import files, for example \'CC {}\'.'.format(CURRENT_YEAR))
parser.add_argument('-n', '--dry-run',
action='store_true',
dest='dry_run',
required=False,
help='Performs a dry run (outputs import commands to stdout).')
parser.add_argument('-x', '--xattr',
action='store_true',
dest='clear_xattr',
required=False,
help='Clears extended attributes (prompts for password for sudo).')
args = parser.parse_args()
_packages = _walk(args.adobe_dir, args.suffix)
if not _packages:
print('No Adobe packages found. Existince is pain.')
sys.exit(1)
if args.clear_xattr:
_xattr(args.adobe_dir)
if args.munki_repo:
_munki_repo = args.munki_repo
_munki_pkginfo = Path(_munki_repo) / 'pkgsinfo/{}'.format(args.munki_subdir)
_pkginfos = {_f for _f in _munki_pkginfo.glob('*')}
for _k, _v in _packages.items():
_app_ver = None
_pkg_name = None
_ac_receipts = None
_installer = _v.get('installer', None)
_installer_rename = _v.get('installer_rename', None)
_opt_xml = Path(_installer) / 'Contents/Resources/optionXML.xml' if _installer else None
_uninstaller = _v.get('uninstaller', None)
_uninstaller_rename = _v.get('uninstaller_rename', None)
_ac_dmg = _v.get('acrobat_dmg', None)
if _opt_xml.exists():
_xml = _app_version_info(xmldoc=str(_opt_xml))
_pkg_name = _xml.get('package_name', None)
_products = _xml.get('products', None)
if _ac_dmg:
_tmpdir = str(Path(tempfile.gettempdir()) / 'acrobat')
_vol = _mount_dmg(_ac_dmg)
if Path(_vol).exists():
_ac_pkg = Path(_vol) / 'Acrobat DC'
try:
_ac_pkg = [_f for _f in _ac_pkg.glob('*.pkg')][0]
except IndexError:
_ac_pkg = None
if _ac_pkg and PurePath(_ac_pkg).suffix == '.pkg':
_expand_pkg(_ac_pkg, _tmpdir)
_dist_xml = Path(_tmpdir) / 'Distribution'
_ac_receipts = _acrobat_dc_receipts(_dist_xml)
_app_ver = [_x['version'] for _x in _ac_receipts if _x['packageid'] == ACROBAT_APP_VER_ATTRIB][0]
shutil.rmtree(_tmpdir)
_detach_dmg(str(_vol))
else:
for _prd in _products:
if _prd.get('SAPCode', None) in SAP_CODES:
_app_ver = _prd.get('productVersion', None)
if args.suffix and args.suffix not in _pkg_name:
_pkg_name = '{} {}'.format(_pkg_name, args.suffix)
_inst_pkginfo = str(PurePath(_installer_rename).name).replace('.pkg', '-{}.plist'.format(_app_ver))
_munki_name = str(PurePath(PurePath(_inst_pkginfo).name).stem).replace('-{}'.format(_app_ver), '')
if args.suffix and 'cc' in args.suffix.lower():
_munki_name = _munki_name.replace('-cc-', '-')
_icon = _munki_name.replace('-installer', '.png')
_installer_pkginfo = Path(_munki_pkginfo) / _inst_pkginfo
if not args.dry_run:
if _installer_rename:
_installer.rename(_installer_rename)
if _uninstaller_rename:
_uninstaller.rename(_uninstaller_rename)
if _installer_pkginfo in _pkginfos:
print('Skipping {} ({}) - already imported'.format(_pkg_name, _inst_pkginfo))
else:
_cmd = ['/usr/local/munki/munkiimport',
'--nointeractive',
'--subdirectory', args.munki_subdir,
'--developer', DEVELOPER,
'--category', CATEGORY,
'--catalog', CATALOG,
'--displayname', '{}'.format(_pkg_name),
'--description', '{}'.format(_pkg_name),
'--name', _munki_name, # os.path.basename(os.path.splitext(_new_inst_fn)[0]),
'--icon', _icon,
'--minimum-munki-version', '2.1',
'--minimum_os_version', '10.13',
str(_installer_rename)]
if _uninstaller_rename:
_cmd.extend(['--uninstallerpkg', str(_uninstaller_rename)])
if _ac_dmg:
_cmd.extend(['--pkgvers={}'.format(_app_ver)])
if args.dry_run:
print(' '.join(_cmd))
else:
subprocess.check_call(_cmd)
if _ac_dmg and Path(_inst_pkginfo).exists() and _ac_receipts and not args.dry_run:
_pkginfo = _read_plist(str(_inst_pkginfo))
_pkginfo['receipts'] = _ac_receipts
_write_plist(str(_inst_pkginfo), _pkginfo)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment