diff --git a/README.md b/README.md index 7759c22..f98b1d3 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Some scripts that might be of use to macOS admins. Might be related to Munki; might not. -These are currently only supported using Apple's Python on macOS. There is no support for running these on Windows or Linux. +These are only supported on macOS. There is no support for running these on Windows or Linux. -In macOS 12.3, Apple will be removing its Python 2.7 install. You'll need to provide your own Python to use these scripts. You may also need to install additional Python modules. +In macOS 12.3, Apple stopped providung Python as part of macOS. You'll need to provide your own Python to use these scripts. You may also need to install additional Python modules. #### getmacosipsws.py diff --git a/installinstallmacos.py b/installinstallmacos.py index 46fc413..4932c43 100755 --- a/installinstallmacos.py +++ b/installinstallmacos.py @@ -33,6 +33,8 @@ import plistlib import subprocess import sys +import platform + try: # python 2 from urllib.parse import urlsplit @@ -42,6 +44,9 @@ from xml.dom import minidom from xml.parsers.expat import ExpatError +# disable pylint's suggestions about using f-strings +# pylint: disable=C0209 + try: import xattr except ImportError: @@ -66,6 +71,18 @@ '21': 'https://swscan.apple.com/content/catalogs/others/' 'index-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', + '22': 'https://swscan.apple.com/content/catalogs/others/' + 'index-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' + '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', + '23': 'https://swscan.apple.com/content/catalogs/others/' + 'index-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' + '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', + '24': 'https://swscan.apple.com/content/catalogs/others/' + 'index-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' + '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog', + '25': 'https://swscan.apple.com/content/catalogs/others/' + 'index-26-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9' + '-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog' } SEED_CATALOGS_PLIST = ( @@ -90,7 +107,9 @@ def read_plist(filepath): return plistlib.load(fileobj) except AttributeError: # plistlib module doesn't have a load function (as in Python 2) + # pylint: disable=E1101 return plistlib.readPlist(filepath) + # pylint: enable=E1101 def read_plist_from_string(bytestring): @@ -99,7 +118,9 @@ def read_plist_from_string(bytestring): return plistlib.loads(bytestring) except AttributeError: # plistlib module doesn't have a load function (as in Python 2) + # pylint: disable=E1101 return plistlib.readPlistFromString(bytestring) + # pylint: enable=E1101 def get_seeding_program(sucatalog_url): @@ -143,22 +164,24 @@ def get_default_catalog(): def make_sparse_image(volume_name, output_path): '''Make a sparse disk image we can install a product to''' - cmd = ['/usr/bin/hdiutil', 'create', '-size', '16g', '-fs', 'HFS+', + # note: for macOS 26 Tahoe we needed to increase the size + cmd = ['/usr/bin/hdiutil', 'create', '-size', '20g', '-fs', 'HFS+', '-volname', volume_name, '-type', 'SPARSE', '-plist', output_path] try: output = subprocess.check_output(cmd) except subprocess.CalledProcessError as err: print(err, file=sys.stderr) - exit(-1) + sys.exit(-1) try: - return read_plist_from_string(output)[0] - except IndexError as err: + output = read_plist_from_string(output)[0] + except IndexError: print('Unexpected output from hdiutil: %s' % output, file=sys.stderr) - exit(-1) + sys.exit(-1) except ExpatError as err: print('Malformed output from hdiutil: %s' % output, file=sys.stderr) print(err, file=sys.stderr) - exit(-1) + sys.exit(-1) + return output def make_compressed_dmg(app_path, diskimagepath): @@ -220,41 +243,86 @@ def unmountdmg(mountpoint): print('Failed to unmount %s' % mountpoint, file=sys.stderr) +def is_legacy(title): + """Returns a boolean to tell us if this is a pre-Big Sur installer""" + return "Sierra" in title or "Mojave" in title or "Catalina" in title + + +def macOsVersion(only_major_minor=True): + """Returns a macOS version as a tuple of integers + + Args: + only_major_minor: Boolean. If True, only include major/minor versions. + """ + # platform.mac_ver() returns 10.16-style version info on Big Sur + # and is likely to do so until Python is compiled with the macOS 11 SDK + # which may not happen for a while. And Apple's odd tricks mean that even + # reading /System/Library/CoreServices/SystemVersion.plist is unreliable. + # So let's use a different method. + try: + os_version_tuple = subprocess.check_output( + ('/usr/bin/sw_vers', '-productVersion'), + env={'SYSTEM_VERSION_COMPAT': '0'} + ).decode('UTF-8').rstrip().split('.') + except subprocess.CalledProcessError: + # fall back to platform.mac_ver() + os_version_tuple = platform.mac_ver()[0].split(".") + if only_major_minor: + os_version_tuple = os_version_tuple[0:2] + return tuple(map(int, os_version_tuple)) + + def install_product(dist_path, target_vol): '''Install a product to a target volume. Returns a boolean to indicate success or failure.''' # set CM_BUILD env var to make Installer bypass eligibilty checks # when installing packages (for machine-specific OS builds) os.environ["CM_BUILD"] = "CM_BUILD" - cmd = ['/usr/sbin/installer', '-pkg', dist_path, '-target', target_vol] + # check if running on Sequoia 15.6+ + if macOsVersion() >= (15,6): + # work around a change in macOS 15.6+ since installing a .dist + # file no longer works + # find InstallAssistant.pkg and install that instead + dist_dir = os.path.dirname(dist_path) + install_asst_pkg = os.path.join(dist_dir, "InstallAssistant.pkg") + if not os.path.exists(install_asst_pkg): + print("*** Error: InstallAssistant.pkg not found.", file=sys.stderr) + return False + cmd = ['/usr/sbin/installer', '-pkg', install_asst_pkg, '-target', target_vol] + else: + # pre-macOS 15.6 install method + # (install the .dist, which acts like installing a distribution pkg) + cmd = ['/usr/sbin/installer', '-pkg', dist_path, '-target', target_vol] + try: subprocess.check_call(cmd) except subprocess.CalledProcessError as err: print(err, file=sys.stderr) return False - else: - # Apple postinstall script bug ends up copying files to a path like - # /tmp/dmg.T9ak1HApplications - path = target_vol + 'Applications' - if os.path.exists(path): - print('*********************************************************') - print('*** Working around a very dumb Apple bug in a package ***') - print('*** postinstall script that fails to correctly target ***') - print('*** the Install macOS.app when installed to a volume ***') - print('*** other than the current boot volume. ***') - print('*** Please file feedback with Apple! ***') - print('*********************************************************') - subprocess.check_call( - ['/usr/bin/ditto', - path, - os.path.join(target_vol, 'Applications')] - ) - subprocess.check_call(['/bin/rm', '-r', path]) - return True + + # Apple postinstall script bug ends up copying files to a path like + # /tmp/dmg.T9ak1HApplications + path = target_vol + 'Applications' + if os.path.exists(path): + print('*********************************************************') + print('*** Working around a very dumb Apple bug in a package ***') + print('*** postinstall script that fails to correctly target ***') + print('*** the Install macOS.app when installed to a volume ***') + print('*** other than the current boot volume. ***') + print('*** Please file feedback with Apple! ***') + print('*********************************************************') + subprocess.check_call( + ['/usr/bin/ditto', + path, + os.path.join(target_vol, 'Applications')] + ) + subprocess.check_call(['/bin/rm', '-r', path]) + return True + class ReplicationError(Exception): '''A custom error when replication fails''' - pass + #pass def replicate_url(full_url, @@ -294,7 +362,7 @@ def replicate_url(full_url, print("Downloading %s..." % full_url) need_download = False try: - output = subprocess.check_output(curl_cmd) + _ = subprocess.check_output(curl_cmd) except subprocess.CalledProcessError as err: if not resumed or not err.output.isdigit(): raise ReplicationError(err) @@ -400,7 +468,7 @@ def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False): sucatalog, root_dir=workdir, ignore_cache=ignore_cache) except ReplicationError as err: print('Could not replicate %s: %s' % (sucatalog, err), file=sys.stderr) - exit(-1) + sys.exit(-1) if os.path.splitext(localcatalogpath)[1] == '.gz': with gzip.open(localcatalogpath) as the_file: content = the_file.read() @@ -410,7 +478,7 @@ def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False): except ExpatError as err: print('Error reading %s: %s' % (localcatalogpath, err), file=sys.stderr) - exit(-1) + sys.exit(-1) else: try: catalog = read_plist(localcatalogpath) @@ -418,7 +486,7 @@ def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False): except (OSError, IOError, ExpatError) as err: print('Error reading %s: %s' % (localcatalogpath, err), file=sys.stderr) - exit(-1) + sys.exit(-1) def find_mac_os_installers(catalog): @@ -469,7 +537,7 @@ def os_installer_product_info(catalog, workdir, ignore_cache=False): product_info[product_key]['title'] = dist_info.get('title_from_dist') if not product_info[product_key]['version']: product_info[product_key]['version'] = dist_info.get('VERSION') - + return product_info @@ -483,13 +551,16 @@ def replicate_product(catalog, product_id, workdir, ignore_cache=False): if 'URL' in package: try: replicate_url( - package['URL'], root_dir=workdir, - show_progress=True, ignore_cache=ignore_cache, - attempt_resume=(not ignore_cache)) + package['URL'], + root_dir=workdir, + show_progress=True, + ignore_cache=ignore_cache, + attempt_resume=not ignore_cache + ) except ReplicationError as err: print('Could not replicate %s: %s' % (package['URL'], err), file=sys.stderr) - exit(-1) + sys.exit(-1) if 'MetadataURL' in package: try: replicate_url(package['MetadataURL'], root_dir=workdir, @@ -497,7 +568,7 @@ def replicate_product(catalog, product_id, workdir, ignore_cache=False): except ReplicationError as err: print('Could not replicate %s: %s' % (package['MetadataURL'], err), file=sys.stderr) - exit(-1) + sys.exit(-1) def find_installer_app(mountpoint): @@ -559,13 +630,13 @@ def main(): % args.seedprogram, file=sys.stderr) print('Valid seeding programs are: %s' % ', '.join(get_seeding_programs()), file=sys.stderr) - exit(-1) + sys.exit(-1) else: su_catalog_url = get_default_catalog() if not su_catalog_url: print('Could not find a default catalog url for this OS version.', file=sys.stderr) - exit(-1) + sys.exit(-1) # download sucatalog and look for products that are for macOS installers catalog = download_and_parse_sucatalog( @@ -576,7 +647,7 @@ def main(): if not product_info: print('No macOS installer products found in the sucatalog.', file=sys.stderr) - exit(-1) + sys.exit(-1) # display a menu of choices (some seed catalogs have multiple installers) print('%2s %14s %10s %8s %11s %s' @@ -600,7 +671,18 @@ def main(): product_id = list(product_info.keys())[index] except (ValueError, IndexError): print('Exiting.') - exit(0) + sys.exit(0) + + if is_legacy(product_info[product_id].get('title','')): + # Catalina and earlier do not have InstallAssistant.pkg, and + # InstallAssistantAuto.pkg (which they do have) do not work for + # installing the Install macOS.app + print( + '*** Error: building High Sierra, Mojave, and Catalina installer images\n' + '*** is unsupported on macOS Sequoia 15.6 and later due to breaking changes\n' + '*** in /usr/sbin/installer by Apple.', file=sys.stderr + ) + sys.exit(1) # download all the packages for the selected product replicate_product( @@ -626,7 +708,7 @@ def main(): if not success: print('Product installation failed.', file=sys.stderr) unmountdmg(mountpoint) - exit(-1) + sys.exit(-1) # add the seeding program xattr to the app if applicable seeding_program = get_seeding_program(su_catalog_url) if seeding_program: @@ -634,7 +716,8 @@ def main(): if installer_app: print("Adding seeding program %s extended attribute to app" % seeding_program) - xattr.setxattr(installer_app, 'SeedProgram', seeding_program) + xattr.setxattr(installer_app, 'SeedProgram', + seeding_program.encode("UTF-8")) print('Product downloaded and installed to %s' % sparse_diskimage_path) if args.raw: unmountdmg(mountpoint) diff --git a/munki_bundle_pkg_finder.py b/munki_bundle_pkg_finder.py new file mode 100755 index 0000000..3538774 --- /dev/null +++ b/munki_bundle_pkg_finder.py @@ -0,0 +1,62 @@ +#!/usr/local/munki/munki-python + +import os +import plistlib +import sys + +sys.path.append("/usr/local/munki") + +from munkilib import dmgutils +from munkilib import pkgutils + +if len(sys.argv) != 2: + print('Need exactly one parameter: path to a munki repo!', file=sys.stderr) + sys.exit(-1) + +repo_path = sys.argv[1] + +all_catalog = os.path.join(repo_path, "catalogs/all") + +with open(all_catalog, mode="rb") as FILE: + all_items = plistlib.load(FILE) + +dmg_items = [{"name": item["name"], + "version": item["version"], + "location": item["installer_item_location"], + "package_path": item.get("package_path", "")} + for item in all_items + if item.get("installer_item_location", "").endswith(".dmg") and + item.get("installer_type") is None] + +items_with_bundle_style_pkgs = [] +for item in dmg_items: + full_path = os.path.join(repo_path, "pkgs", item["location"]) + print("Checking %s..." % full_path) + mountpoints = dmgutils.mountdmg(full_path) + if mountpoints: + pkg_path = item["package_path"] + if pkg_path: + itempath = os.path.join(mountpoints[0], pkg_path) + if os.path.isdir(itempath): + print("***** %s--%s has a bundle-style pkg" + % (item["name"], item["version"])) + items_with_bundle_style_pkgs.append(item) + else: + for file_item in os.listdir(mountpoints[0]): + if pkgutils.hasValidInstallerItemExt(file_item): + itempath = os.path.join(mountpoints[0], file_item) + if os.path.isdir(itempath): + print("***** %s--%s has a bundle-style pkg" + % (item["name"], item["version"])) + items_with_bundle_style_pkgs.append(item) + break + dmgutils.unmountdmg(mountpoints[0]) + else: + print("No filesystems mounted from %s" % full_path) + continue + +print("Found %s items with bundle-style pkgs." + % len(items_with_bundle_style_pkgs)) +for item in sorted(items_with_bundle_style_pkgs, key=lambda d: d["name"]): + print("%s--%s"% (item["name"], item["version"])) + print(" %s" % item["location"])