mirror of
https://github.com/grahampugh/macadmin-scripts.git
synced 2025-12-17 17:56:33 +00:00
Merge remote-tracking branch 'upstream/master', added --validate option
This commit is contained in:
commit
691725e61f
9
.gitignore
vendored
9
.gitignore
vendored
@ -1,7 +1,16 @@
|
|||||||
|
# .DS_Store
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# disk images
|
# disk images
|
||||||
*.dmg
|
*.dmg
|
||||||
*.sparseimage
|
*.sparseimage
|
||||||
|
|
||||||
|
# .pyc and .pyo files
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
# our content directory
|
# our content directory
|
||||||
content/
|
content/
|
||||||
|
|
||||||
|
# the outputted list file
|
||||||
|
softwareupdate.plist
|
||||||
|
|||||||
46
README.md
46
README.md
@ -1,4 +1,48 @@
|
|||||||
macadmin-scripts
|
### macadmin-scripts
|
||||||
|
|
||||||
Some scripts that might be of use to macOS admins. Might be related to Munki;
|
Some scripts that might be of use to macOS admins. Might be related to Munki;
|
||||||
might not.
|
might not.
|
||||||
|
|
||||||
|
#### createbootvolfromautonbi.py
|
||||||
|
|
||||||
|
A tool to make bootable disk volumes from the output of autonbi. Especially
|
||||||
|
useful to make bootable disks containing Imagr and the 'SIP-ignoring' kernel,
|
||||||
|
which allows Imagr to run scripts that affect SIP state, set UAKEL options, and
|
||||||
|
run the `startosinstall` component, all of which might otherwise require network
|
||||||
|
booting from a NetInstall-style nbi.
|
||||||
|
|
||||||
|
This provides a way to create a bootable external disk that acts like the Netboot environment used by/needed by Imagr.
|
||||||
|
|
||||||
|
This command converts the output of Imagr's `make nbi` into a bootable external USB disk:
|
||||||
|
`sudo ./createbootvolfromautonbi.py --nbi ~/Desktop/10.13.6_Imagr.nbi --volume /Volumes/ExternalDisk`
|
||||||
|
|
||||||
|
#### installinstallmacos.py
|
||||||
|
|
||||||
|
This script can create disk images containing macOS Installer applications available via Apple's softwareupdate catalogs.
|
||||||
|
|
||||||
|
It does this by downloading the packages from Apple's softwareupdate servers and then installing them into a new empty disk image.
|
||||||
|
|
||||||
|
Since it is using Apple's installer, any install check or volume check scripts are run. This means that you can only use this tool to create a diskimage containing the versions of macOS that will run on the exact machine you are running the script on.
|
||||||
|
|
||||||
|
For example, to create a diskimage containing the version 10.13.6 that runs on 2018 MacBook Pros, you must run this script on a 2018 MacBook Pro, and choose the proper version.
|
||||||
|
|
||||||
|
Typically "forked" OS build numbers are 4 digits, so when this document was last updated, build 17G2208 was the correct build for 2018 MacBook Pros; 17G65 was the correct build for all other Macs that support High Sierra.
|
||||||
|
|
||||||
|
If you attempt to install an incompatible version of macOS, you'll see an error similar to the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
Making empty sparseimage...
|
||||||
|
installer: Error - ERROR_B14B14D9B7
|
||||||
|
Command '['/usr/sbin/installer', '-pkg', './content/downloads/07/20/091-95774/awldiototubemmsbocipx0ic9lj2kcu0pt/091-95774.English.dist', '-target', '/private/tmp/dmg.Hf0PHy']' returned non-zero exit status 1
|
||||||
|
Product installation failed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a compatible Mac or select a different build compatible with your current hardware and try again.
|
||||||
|
|
||||||
|
Run `./installinstallmacos.py --help` to see the available options.
|
||||||
|
|
||||||
|
#### make_firmwareupdater_pkg.sh
|
||||||
|
|
||||||
|
This script was used to extract the firmware updaters from early High Sierra installers and make a standalone installer package that could be used to upgrade Mac firmware before installing High Sierra via imaging.
|
||||||
|
|
||||||
|
Later High Sierra installer changes have broken this script; since installing High Sierra via imaging is not recommended or supported by Apple and several other alternatives are now available, I don't plan on attempting to fix or upgrade this tool.
|
||||||
|
|||||||
@ -25,11 +25,15 @@ empty disk image'''
|
|||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import gzip
|
||||||
import os
|
import os
|
||||||
import plistlib
|
import plistlib
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urlparse
|
import urlparse
|
||||||
|
import xattr
|
||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
from xml.parsers.expat import ExpatError
|
from xml.parsers.expat import ExpatError
|
||||||
|
|
||||||
@ -37,7 +41,57 @@ from xml.parsers.expat import ExpatError
|
|||||||
DEFAULT_SUCATALOG = (
|
DEFAULT_SUCATALOG = (
|
||||||
'https://swscan.apple.com/content/catalogs/others/'
|
'https://swscan.apple.com/content/catalogs/others/'
|
||||||
'index-10.13seed-10.13-10.12-10.11-10.10-10.9'
|
'index-10.13seed-10.13-10.12-10.11-10.10-10.9'
|
||||||
'-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog')
|
'-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz')
|
||||||
|
|
||||||
|
SEED_CATALOGS_PLIST = (
|
||||||
|
'/System/Library/PrivateFrameworks/Seeding.framework/Versions/Current/'
|
||||||
|
'Resources/SeedCatalogs.plist'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_board_id():
|
||||||
|
'''Gets the local system board ID'''
|
||||||
|
ioreg_cmd = ['ioreg', '-p', 'IODeviceTree', '-r', '-n', '/', '-d', '1']
|
||||||
|
try:
|
||||||
|
ioreg_output = subprocess.check_output(ioreg_cmd).splitlines()
|
||||||
|
for line in ioreg_output:
|
||||||
|
if 'board-id' in line:
|
||||||
|
board_id = line.split(" ")[-1]
|
||||||
|
board_id = board_id[board_id.find('<"')+2:board_id.find('">')]
|
||||||
|
return board_id
|
||||||
|
except subprocess.CalledProcessError, err:
|
||||||
|
raise ReplicationError(err)
|
||||||
|
|
||||||
|
|
||||||
|
def get_hw_model():
|
||||||
|
'''Gets the local system ModelIdentifier'''
|
||||||
|
sysctl_cmd = ['/usr/sbin/sysctl', 'hw.model']
|
||||||
|
try:
|
||||||
|
sysctl_output = subprocess.check_output(sysctl_cmd)
|
||||||
|
hw_model = sysctl_output.split(" ")[-1].split("\n")[0]
|
||||||
|
except subprocess.CalledProcessError, err:
|
||||||
|
raise ReplicationError(err)
|
||||||
|
return hw_model
|
||||||
|
|
||||||
|
|
||||||
|
def get_seeding_program(sucatalog_url):
|
||||||
|
'''Returns a seeding program name based on the sucatalog_url'''
|
||||||
|
try:
|
||||||
|
seed_catalogs = plistlib.readPlist(SEED_CATALOGS_PLIST)
|
||||||
|
for key, value in seed_catalogs.items():
|
||||||
|
if sucatalog_url == value:
|
||||||
|
return key
|
||||||
|
except (OSError, ExpatError, AttributeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_seed_catalog():
|
||||||
|
'''Returns the developer seed sucatalog'''
|
||||||
|
try:
|
||||||
|
seed_catalogs = plistlib.readPlist(SEED_CATALOGS_PLIST)
|
||||||
|
return seed_catalogs.get('DeveloperSeed', DEFAULT_SUCATALOG)
|
||||||
|
except (OSError, ExpatError, AttributeError, KeyError):
|
||||||
|
return DEFAULT_SUCATALOG
|
||||||
|
|
||||||
|
|
||||||
def make_sparse_image(volume_name, output_path):
|
def make_sparse_image(volume_name, output_path):
|
||||||
@ -153,7 +207,7 @@ def replicate_url(full_url, root_dir='/tmp',
|
|||||||
if not ignore_cache and os.path.exists(local_file_path):
|
if not ignore_cache and os.path.exists(local_file_path):
|
||||||
curl_cmd.extend(['-z', local_file_path])
|
curl_cmd.extend(['-z', local_file_path])
|
||||||
curl_cmd.append(full_url)
|
curl_cmd.append(full_url)
|
||||||
print "Downloading %s..." % full_url
|
# print "Downloading %s..." % full_url
|
||||||
try:
|
try:
|
||||||
subprocess.check_call(curl_cmd)
|
subprocess.check_call(curl_cmd)
|
||||||
except subprocess.CalledProcessError, err:
|
except subprocess.CalledProcessError, err:
|
||||||
@ -241,6 +295,30 @@ def parse_dist(filename):
|
|||||||
return dist_info
|
return dist_info
|
||||||
|
|
||||||
|
|
||||||
|
def get_board_ids(filename):
|
||||||
|
'''Parses a softwareupdate dist file, returning a list of supported
|
||||||
|
Board IDs'''
|
||||||
|
supported_board_ids = ""
|
||||||
|
with open(filename) as search:
|
||||||
|
for line in search:
|
||||||
|
line = line.rstrip() # remove '\n' at end of line
|
||||||
|
if 'boardIds' in line:
|
||||||
|
supported_board_ids = line.split(" ")[-1][:-1]
|
||||||
|
return supported_board_ids
|
||||||
|
|
||||||
|
|
||||||
|
def get_unsupported_models(filename):
|
||||||
|
'''Parses a softwareupdate dist file, returning a list of non-supported
|
||||||
|
ModelIdentifiers'''
|
||||||
|
unsupported_models = ""
|
||||||
|
with open(filename) as search:
|
||||||
|
for line in search:
|
||||||
|
line = line.rstrip() # remove '\n' at end of line
|
||||||
|
if 'nonSupportedModels' in line:
|
||||||
|
unsupported_models = line.split(" ")[-1][:-1]
|
||||||
|
return unsupported_models
|
||||||
|
|
||||||
|
|
||||||
def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False):
|
def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False):
|
||||||
'''Downloads and returns a parsed softwareupdate catalog'''
|
'''Downloads and returns a parsed softwareupdate catalog'''
|
||||||
try:
|
try:
|
||||||
@ -249,6 +327,17 @@ def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False):
|
|||||||
except ReplicationError, err:
|
except ReplicationError, err:
|
||||||
print >> sys.stderr, 'Could not replicate %s: %s' % (sucatalog, err)
|
print >> sys.stderr, 'Could not replicate %s: %s' % (sucatalog, err)
|
||||||
exit(-1)
|
exit(-1)
|
||||||
|
if os.path.splitext(localcatalogpath)[1] == '.gz':
|
||||||
|
with gzip.open(localcatalogpath) as f:
|
||||||
|
content = f.read()
|
||||||
|
try:
|
||||||
|
catalog = plistlib.readPlistFromString(content)
|
||||||
|
return catalog
|
||||||
|
except ExpatError, err:
|
||||||
|
print >> sys.stderr, (
|
||||||
|
'Error reading %s: %s' % (localcatalogpath, err))
|
||||||
|
exit(-1)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
catalog = plistlib.readPlist(localcatalogpath)
|
catalog = plistlib.readPlist(localcatalogpath)
|
||||||
return catalog
|
return catalog
|
||||||
@ -285,7 +374,7 @@ def os_installer_product_info(catalog, workdir, ignore_cache=False):
|
|||||||
filename = get_server_metadata(catalog, product_key, workdir)
|
filename = get_server_metadata(catalog, product_key, workdir)
|
||||||
product_info[product_key] = parse_server_metadata(filename)
|
product_info[product_key] = parse_server_metadata(filename)
|
||||||
product = catalog['Products'][product_key]
|
product = catalog['Products'][product_key]
|
||||||
product_info[product_key]['PostDate'] = str(product['PostDate'])
|
product_info[product_key]['PostDate'] = product['PostDate']
|
||||||
distributions = product['Distributions']
|
distributions = product['Distributions']
|
||||||
dist_url = distributions.get('English') or distributions.get('en')
|
dist_url = distributions.get('English') or distributions.get('en')
|
||||||
try:
|
try:
|
||||||
@ -295,6 +384,10 @@ def os_installer_product_info(catalog, workdir, ignore_cache=False):
|
|||||||
print >> sys.stderr, 'Could not replicate %s: %s' % (dist_url, err)
|
print >> sys.stderr, 'Could not replicate %s: %s' % (dist_url, err)
|
||||||
dist_info = parse_dist(dist_path)
|
dist_info = parse_dist(dist_path)
|
||||||
product_info[product_key]['DistributionPath'] = dist_path
|
product_info[product_key]['DistributionPath'] = dist_path
|
||||||
|
unsupported_models = get_unsupported_models(dist_path)
|
||||||
|
product_info[product_key]['UnsupportedModels'] = unsupported_models
|
||||||
|
board_ids = get_board_ids(dist_path)
|
||||||
|
product_info[product_key]['BoardIDs'] = board_ids
|
||||||
product_info[product_key].update(dist_info)
|
product_info[product_key].update(dist_info)
|
||||||
|
|
||||||
return product_info
|
return product_info
|
||||||
@ -326,16 +419,30 @@ def replicate_product(catalog, product_id, workdir, ignore_cache=False):
|
|||||||
% (package['MetadataURL'], err))
|
% (package['MetadataURL'], err))
|
||||||
exit(-1)
|
exit(-1)
|
||||||
|
|
||||||
|
|
||||||
|
def find_installer_app(mountpoint):
|
||||||
|
'''Returns the path to the Install macOS app on the mountpoint'''
|
||||||
|
applications_dir = os.path.join(mountpoint, 'Applications')
|
||||||
|
for item in os.listdir(applications_dir):
|
||||||
|
if item.endswith('.app'):
|
||||||
|
return os.path.join(applications_dir, item)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
'''Do the main thing here'''
|
'''Do the main thing here'''
|
||||||
|
|
||||||
|
print
|
||||||
|
print "installinstallmacos.py - get macOS installers from Apple's software catalog"
|
||||||
|
print
|
||||||
|
|
||||||
if os.getuid() != 0:
|
if os.getuid() != 0:
|
||||||
sys.exit('This command requires root (to install packages), so please '
|
sys.exit('This command requires root (to install packages), so please '
|
||||||
'run again with sudo or as root.')
|
'run again with sudo or as root.')
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('--catalogurl', metavar='sucatalog_url',
|
parser.add_argument('--catalogurl', metavar='sucatalog_url',
|
||||||
default=DEFAULT_SUCATALOG,
|
default=get_seed_catalog(),
|
||||||
help='Software Update catalog URL.')
|
help='Software Update catalog URL.')
|
||||||
parser.add_argument('--workdir', metavar='path_to_working_dir',
|
parser.add_argument('--workdir', metavar='path_to_working_dir',
|
||||||
default='.',
|
default='.',
|
||||||
@ -344,7 +451,13 @@ def main():
|
|||||||
'directory.')
|
'directory.')
|
||||||
parser.add_argument('--compress', action='store_true',
|
parser.add_argument('--compress', action='store_true',
|
||||||
help='Output a read-only compressed disk image with '
|
help='Output a read-only compressed disk image with '
|
||||||
'the Install macOS app at the root.')
|
'the Install macOS app at the root. This is now the '
|
||||||
|
'default. Use --raw to get a read-write sparse image '
|
||||||
|
'with the app in the Applications directory.')
|
||||||
|
parser.add_argument('--raw', action='store_true',
|
||||||
|
help='Output a read-write sparse image '
|
||||||
|
'with the app in the Applications directory. Requires '
|
||||||
|
'less available disk space and is faster.')
|
||||||
parser.add_argument('--ignore-cache', action='store_true',
|
parser.add_argument('--ignore-cache', action='store_true',
|
||||||
help='Ignore any previously cached files.')
|
help='Ignore any previously cached files.')
|
||||||
parser.add_argument('--build', metavar='build_version',
|
parser.add_argument('--build', metavar='build_version',
|
||||||
@ -354,8 +467,19 @@ def main():
|
|||||||
parser.add_argument('--list', action='store_true',
|
parser.add_argument('--list', action='store_true',
|
||||||
help='Output the available updates to a plist '
|
help='Output the available updates to a plist '
|
||||||
'and quit.')
|
'and quit.')
|
||||||
|
parser.add_argument('--validate', action='store_true',
|
||||||
|
help='Validate builds for board ID and hardware model '
|
||||||
|
'and only show appropriate builds.')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# show this Mac's hardware model
|
||||||
|
hw_model = get_hw_model()
|
||||||
|
print "This Mac's ModelIdentifier: %s" % hw_model
|
||||||
|
# show this Mac's board-id
|
||||||
|
board_id = get_board_id()
|
||||||
|
print "This Mac's Board ID: %s" % board_id
|
||||||
|
print
|
||||||
|
|
||||||
# download sucatalog and look for products that are for macOS installers
|
# download sucatalog and look for products that are for macOS installers
|
||||||
catalog = download_and_parse_sucatalog(
|
catalog = download_and_parse_sucatalog(
|
||||||
args.catalogurl, args.workdir, ignore_cache=args.ignore_cache)
|
args.catalogurl, args.workdir, ignore_cache=args.ignore_cache)
|
||||||
@ -372,14 +496,22 @@ def main():
|
|||||||
pl['result'] = []
|
pl['result'] = []
|
||||||
|
|
||||||
# display a menu of choices (some seed catalogs have multiple installers)
|
# display a menu of choices (some seed catalogs have multiple installers)
|
||||||
print '%2s %12s %10s %8s %s' % ('#', 'ProductID', 'Version',
|
print '%2s %12s %10s %8s %11s %s' % ('#', 'ProductID', 'Version',
|
||||||
'Build', 'Title')
|
'Build', 'Post Date', 'Title')
|
||||||
for index, product_id in enumerate(product_info):
|
for index, product_id in enumerate(product_info):
|
||||||
print '%2s %12s %10s %8s %s' % (index+1,
|
if args.validate:
|
||||||
|
if board_id not in product_info[product_id]['BoardIDs']:
|
||||||
|
continue
|
||||||
|
if hw_model in product_info[product_id]['UnsupportedModels']:
|
||||||
|
continue
|
||||||
|
print '%2s %12s %10s %8s %11s %s' % (
|
||||||
|
index + 1,
|
||||||
product_id,
|
product_id,
|
||||||
product_info[product_id]['version'],
|
product_info[product_id]['version'],
|
||||||
product_info[product_id]['BUILD'],
|
product_info[product_id]['BUILD'],
|
||||||
product_info[product_id]['title'])
|
product_info[product_id]['PostDate'].strftime('%Y-%m-%d'),
|
||||||
|
product_info[product_id]['title']
|
||||||
|
)
|
||||||
|
|
||||||
pl_index = {'index': index+1,
|
pl_index = {'index': index+1,
|
||||||
'product_id': product_id,
|
'product_id': product_id,
|
||||||
@ -448,22 +580,25 @@ def main():
|
|||||||
print >> sys.stderr, 'Product installation failed.'
|
print >> sys.stderr, 'Product installation failed.'
|
||||||
unmountdmg(mountpoint)
|
unmountdmg(mountpoint)
|
||||||
exit(-1)
|
exit(-1)
|
||||||
|
# add the seeding program xattr to the app if applicable
|
||||||
|
seeding_program = get_seeding_program(args.catalogurl)
|
||||||
|
if seeding_program:
|
||||||
|
installer_app = find_installer_app(mountpoint)
|
||||||
|
if installer_app:
|
||||||
|
xattr.setxattr(installer_app, 'SeedProgram', seeding_program)
|
||||||
print 'Product downloaded and installed to %s' % sparse_diskimage_path
|
print 'Product downloaded and installed to %s' % sparse_diskimage_path
|
||||||
if not args.compress:
|
if args.raw:
|
||||||
unmountdmg(mountpoint)
|
unmountdmg(mountpoint)
|
||||||
else:
|
else:
|
||||||
# if --compress option given, create a r/o compressed diskimage
|
# if --raw option not given, create a r/o compressed diskimage
|
||||||
# containing the Install macOS app
|
# containing the Install macOS app
|
||||||
compressed_diskimagepath = os.path.join(
|
compressed_diskimagepath = os.path.join(
|
||||||
args.workdir, volname + '.dmg')
|
args.workdir, volname + '.dmg')
|
||||||
if os.path.exists(compressed_diskimagepath):
|
if os.path.exists(compressed_diskimagepath):
|
||||||
os.unlink(compressed_diskimagepath)
|
os.unlink(compressed_diskimagepath)
|
||||||
applications_dir = os.path.join(mountpoint, 'Applications')
|
app_path = find_installer_app(mountpoint)
|
||||||
for item in os.listdir(applications_dir):
|
if app_path:
|
||||||
if item.endswith('.app'):
|
|
||||||
app_path = os.path.join(applications_dir, item)
|
|
||||||
make_compressed_dmg(app_path, compressed_diskimagepath)
|
make_compressed_dmg(app_path, compressed_diskimagepath)
|
||||||
break
|
|
||||||
# unmount sparseimage
|
# unmount sparseimage
|
||||||
unmountdmg(mountpoint)
|
unmountdmg(mountpoint)
|
||||||
# delete sparseimage since we don't need it any longer
|
# delete sparseimage since we don't need it any longer
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user