Created
November 27, 2020 04:11
-
-
Save carlashley/a42d78c31fe922b722b36eac61410b98 to your computer and use it in GitHub Desktop.
Adobe CC Munki Import
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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