===== Автоматизированное развёртывание OC на Apple Mac и мобильные устройства iPhone или iPad =====
Идея подсмотрена на Российской ежегодной [[https://mac.shortcut.ru|конференции]] системных администраторов Apple.
Реализация в целом похожа, в представленном варианте используется **cfgutil**, который запускает скрипт **AppleScript** с логикой обработки подключенных **Mac**, **iPhone** или **iPad**. Последний доступный образ восстановления при необходимости скачивается автоматически. Так же выполняется логирование для понимания процесса. Для перевода Mac в DFU при условии правильного подключения используется **macvdmtool**. iPhone и iPad, само собой, переводим в DFU в ручном режиме.
\\
==== Основная логика ====
Скрипт ''Restore.scpt'' разместим здесь ''/Library/Application Support/Custom/RestoreDevices/''
-- В скрипте используется утилита jq, которая встроена в macOS начиная с Sequoia.
-- При использовании более ранних версий, jq нужно установить дополнительно.
-- Перед использованием с AppStore установить Apple Configurator 2, и выполнить установку средств автоматизации из меню.
--Объявляем глобальную переменную для работы в обработчиках
global LogPath
set LogPath to "/tmp/DevsRestore/logs/"
-- Получаем список подключенных устройств с помощью cfgutil
set cfgutilResult to my cfgutilDevList()
-- Получаем список подключенных устройств с помощью system_profiler
set SPResult to my systemprofilerDevList()
-- Объединяем списки для получения требуемой инф. в одной списке
set MergedList to my MergeLists(cfgutilResult, SPResult)
--return MergedList
-- Проходим по каждому устройству в подписке списка
repeat with i from 1 to count MergedList
-- Берём первое устройство
set thisDevice to item i of MergedList
-- Записываем атрибуты устройства
set DeviceType to item 1 of thisDevice
set DeviceClass to item 2 of thisDevice
set SerialNumber to item 3 of thisDevice
set ECID to item 4 of thisDevice
set LocationID to item 5 of thisDevice
-- Формируем имя Log-файла
set LogFileName to ECID & ".log"
--Логируем найденное устройство
my WriteLog("Найдено устройство:" & space & DeviceType & space & "серийный номер:" & space & SerialNumber & "," & space ¬
& "ECID:" & space & ECID & space & "в порте:" & space & LocationID, LogFileName)
-- Выполняем подготовку
-- Формируем ссылку для получения доступных IPSW
set ChooseUrl to "https://api.ipsw.me/v4/device/" & DeviceType & "?type=ipsw"
--Логируем
my WriteLog("Получаем информацию о последней версии ОС по ссылке:" & space & ChooseUrl, LogFileName)
-- Получаем информацию о последней доступной версии ОС
set BaseJSON to my GetJSON(ChooseUrl, LogFileName)
-- Парсим JSON, если он был получен
if BaseJSON is false then return
-- Получаем контрольную сумму из JSON
set CloudCheckSum to do shell script "echo" & space & quoted form of BaseJSON & space & "| jq -r '.sha1sum'"
-- Получаем ссылку для скачивания "образа" IPSW из JSON
set IPSWUrl to do shell script "echo" & space & quoted form of BaseJSON & space & "| jq -r '.url'"
-- Получаем имя файла из JSON
set CloudIPSWName to do shell script "basename" & space & quoted form of IPSWUrl
-- Указываем родительский каталог для сохранения "образов" IPSW
set IPSWRootPath to (POSIX path of "/Library/Application Support/Custom/RestoreDevices/IPSWs/")
-- Формируем путь до файла "образа" IPSW
set CurrentIPSWPath to (IPSWRootPath & DeviceType & "/" & CloudIPSWName)
-- Если файл существует, сверяем контрольные суммы.
-- Если файла нет -- скачиваем. Скачивание файла может занимать длительное время.
set SelectIPSWStatus to my SelectIPSW(CloudCheckSum, CurrentIPSWPath, IPSWRootPath, DeviceType, IPSWUrl, LogFileName)
-- При возникновении ошибки процесс останавливается
if SelectIPSWStatus is not true then return
-- запускаем восстановление
my Restore(LocationID, ECID, DeviceClass, CurrentIPSWPath, LogFileName)
end repeat
--===================
--Обработчики
--===================
-- Проверить или скачать IPSW
--===================
on SelectIPSW(TrustedCheckSum, thisFile, RootFolder, SubFolder, thisURL, LogFileName)
-- Создаём каталоги для загрузки IPSW, если необходимо
my CreateFolder(RootFolder, SubFolder, LogFileName)
-- Проверяем контрольную сумму существующего файла
set StatusCheckSum to my CheckSum(TrustedCheckSum, thisFile, LogFileName)
if StatusCheckSum is true then
return true
else
-- Скачиваем
set GetIPSWStatus to my GetIPSW(thisFile, thisURL, TrustedCheckSum, LogFileName)
if GetIPSWStatus is true then
return true
else
return false
end if
end if
end SelectIPSW
--===================
-- Получить JSON
--===================
on GetJSON(thisURL, LogFileName)
-- Запрос всех доступных IPSW для подключенной модели
set thisJSON to do shell script "curl" & space & thisURL & space & "| jq '.firmwares'"
-- Если возврат "null", то нет доступных IPSW
if thisJSON is "null" then
my WriteLog("Нет доступных IPSW. Остановка процесса.", LogFileName)
return false
else
-- JSON получен, сортируем элементы массива по дате релиза и берём последний
set thisJSON to do shell script "echo" & space & quoted form of thisJSON & space & "| jq -r 'sort_by(.releasedate) | last'"
my WriteLog("Ответ сервера в формате JSON" & linefeed & thisJSON, LogFileName)
return thisJSON
end if
end GetJSON
--===================
-- Скачать IPSW
--===================
on GetIPSW(thisFile, thisURL, TrustedCheckSum, LogFileName)
-- Поиск параллельных процессов скачивания IPSW образов
set UserName to short user name of (system info)
set PSShellCommand to "ps -u" & space & UserName & space & "| grep '[c]url' | grep" & space & quoted form of thisURL & space & "> /dev/null 2>&1; echo $?"
set CurrentDownloads to (do shell script PSShellCommand) as integer
-- Если процесс curl был найден, возврат 0
if CurrentDownloads is 0 then
-- Устанавливаем счётчик повторных запусков на 0
set Counter to 0
-- Повторяем цикл 5 раз
repeat while Counter < 6
my WriteLog("Обнаружено параллельное скачивание файла по ссылке" & space & thisURL & linefeed & "Проверим ещё раз через 10 минут.", LogFileName)
-- пауза в секундах
delay 600
-- Добавляем к счётчику 1 выполненный проход
set Counter to Counter + 1
-- Проверяем ещё раз
set CurrentDownloads to (do shell script PSShellCommand) as integer
-- Если очередная проверка не обнаружила параллельный процесс, считаем что он завершён
if CurrentDownloads is 1 then
-- Процесс завершён, можно продолжать
my WriteLog("Параллельное скачивание завершено. Повторно выполним проверку контрольной суммы.", LogFileName)
set StatusCheckSum to my CheckSum(TrustedCheckSum, thisFile, LogFileName)
if StatusCheckSum is true then
return true
else
my WriteLog("Возможно, параллельный процесс загрузки был завершён некорректно", LogFileName)
my WriteLog("Попробуйте в ручную загрузить файл по ссылке" & linefeed & thisURL, LogFileName)
my WriteLog("Остановка процесса.", LogFileName)
return false
end if
exit repeat
end if
-- При достижении счётчика, останавливаем процесс
if Counter is 5 then
my WriteLog("За последний час процесс загрузки образа не был завершён или есть зависший процесс curl.", LogFileName)
my WriteLog("Остановка процесса.", LogFileName)
return false
end if
end repeat
-- Если процесс curl не был найден, возврат 1
else if CurrentDownloads is 1 then
set curlShellCommand to "curl -L --retry 5 --retry-delay 10 --retry-all-errors -J -o" & space & quoted form of thisFile & space & thisURL
my WriteLog("Запускаем скачивание:" & linefeed & curlShellCommand, LogFileName)
try
do shell script curlShellCommand
my WriteLog("Файл скачен", LogFileName)
return true
on error errormsg number errNumber
my WriteLog("Проблема скачивания файла. Curl вернул ошибку:" & space & errNumber, LogFileName)
my WriteLog("Коды возврата можно посмотреть в \"man curl\" в разделе \"EXIT CODES\"", LogFileName)
my WriteLog("Остановка процесса.", LogFileName)
return false
end try
end if
end GetIPSW
--===================
--===================
-- Записать лог
on WriteLog(message, logFile)
--do shell script "mkdir -p" & space & quoted form of POSIX path of (do shell script "dirname" & space & quoted form of LogPath)
do shell script "mkdir -p" & space & LogPath
set timeStamp to (do shell script "date +'%Y-%m-%d %H:%M:%S'")
set logMessage to timeStamp & space & "-" & space & message & linefeed
do shell script "echo" & space & quoted form of logMessage & space & ">>" & space & quoted form of (LogPath & logFile)
end WriteLog
--===================
--===================
--Создать каталог для хранения IPSW этой модели
on CreateFolder(RootFolder, SubFolder, LogFileName)
-- Формируем полный путь к каталогу
set targetFolder to RootFolder & SubFolder
-- Проверяем существование каталога
try
-- Классические методы проверки существования каталога или файла используя вызов "System Events"
-- не могут быть использованы, т.к. задача запускает от RootFolder, без GUI.
do shell script "test -d" & space & quoted form of targetFolder
my WriteLog("Каталог для IPSW файла уже существует:" & space & targetFolder, LogFileName)
on error
-- Создать каталог
do shell script "mkdir -p" & space & quoted form of targetFolder
my WriteLog("Создан каталог для сохранения IPSW файла:" & space & targetFolder, LogFileName)
end try
end CreateFolder --===================
--===================
--Сверка контрольной суммы
on CheckSum(TrustedCheckSum, thisFile, LogFileName)
-- Проверяем существование файла
try
-- Классические методы проверки существования каталога или файла используя вызов "System Events"
-- не могут быть использованы, т.к. задача запускает от root, без GUI.
do shell script "test -f" & space & quoted form of thisFile
on error
return false
end try
-- Файл существует, продолжаем проверку
my WriteLog("Начинаем проверку контрольной суммы файла" & space & thisFile, LogFileName)
-- Вычисляем контрольную сумму
set thisCheckSum to word 1 of (do shell script "shasum -a 1" & space & quoted form of thisFile)
if TrustedCheckSum is equal to thisCheckSum then
my WriteLog("Контрольные суммы совпадают", LogFileName)
return true
else
my WriteLog("Контрольные суммы не совпадают:" & space & TrustedCheckSum & space & "≠" & space & thisCheckSum, LogFileName)
return false
end if
end CheckSum
--===================
--===================
--Перезагрузка в DFU
on RebootToDFU(LocationID, DeviceCalss, LogFileName)
-- Список DFU портов.
-- Обычно DFU порт хоста имеет идентификатор "0x100000", но зная Apple, в других модeлях это может быть изменено.
set DFUPortList to {"0x100000"}
set macvdmtool to "/usr/local/bin/macvdmtool"
set macvdmtoolShellCommand to macvdmtool & space & "dfu"
-- Проверяем существование macvdmtool
try
-- Классические методы проверки существования каталога или файла используя вызов "System Events"
-- не могут быть использованы, т.к. задача запускает от root, без GUI.
do shell script "test -f" & space & macvdmtool
-- Если подключенный Mac подключен в DFU порт хоста
if LocationID is in DFUPortList then
my WriteLog("Попытка перезагрузить подключенный" & space & DeviceCalss & space & "в DFU режим" & linefeed & macvdmtoolShellCommand, LogFileName)
try
set macvdmtoolresult to do shell script macvdmtoolShellCommand
my WriteLog("Лог утилиты macvdmtool:" & linefeed & macvdmtoolresult, LogFileName)
my WriteLog("Mac" & space & DeviceCalss & space & "перезагружен в DFU режим", LogFileName)
on error errormsg
my WriteLog("Этот" & space & DeviceCalss & space & "не может быть переведён в DFU режим." & linefeed ¬
& "Убедитесь что это Mac с процессором Apple, кабель подключен в DFU порт и не используется кабель Thunderbol 3/4.", LogFileName)
my WriteLog("Лог утилиты macvdmtool:" & linefeed & errormsg, LogFileName)
my WriteLog("Остановка процесса.", LogFileName)
return false
end try
end if
on error
my WriteLog("Утилита macvdmtool отсутствует. Остановка процесса.", LogFileName)
return false
end try
end RebootToDFU
--===================
--===================
--Восстановление с DFU
on Restore(LocationID, ECID, DeviceCalss, PathToIPSW, LogFileName)
if (DeviceCalss contains "Mac") then
-- Выполняем попытку перезагрузки в DFU режим
set DFUStatus to my RebootToDFU(LocationID, DeviceCalss, LogFileName)
if DFUStatus is false then return
end if
-- Запуск восстановления в фоновом режиме
set cfgutilShellCommand to "/usr/local/bin/cfgutil --progress --verbose -e" & space & ECID & space & "restore -I" & space & quoted form of PathToIPSW & space & ">>" & space & LogPath & ECID & ".log 2>&1 &"
WriteLog("Запуск процесса восстановления:" & linefeed & cfgutilShellCommand, LogFileName)
WriteLog("Лог работы cfgutil:" & linefeed, LogFileName)
do shell script cfgutilShellCommand
end Restore
--===================
--===================
--Поиск подключенных устройств с помощью cfgutil
on cfgutilDevList()
-- Получаем инф. о подключенных устройствах в формате JSON
set BaseJSON to do shell script "/usr/local/bin/cfgutil --format JSON list | jq"
-- Парсим JSON, выбирая нужные данные и сохраняем контент как CVS, разделитель ";"
set ParsedCSV to do shell script "echo" & space & quoted form of BaseJSON & space & "| jq -r '.Output | to_entries[] | [.value.locationID, .value.ECID, .value.deviceType] | join(\";\")'"
-- Разбиваем на строки
set LineCSV to paragraphs of ParsedCSV
-- Пустой итоговый список
set ResultList to {}
-- Цикл обработки строк
repeat with thisDev in LineCSV
-- Используем разделитель ";" для работы с CSV
set text item delimiters to ";"
-- Развиваем строку на части разделителем установленным разделителем
set CSVParts to text items of thisDev
-- Сбрасываем разделитель
set text item delimiters to ""
-- Извлекаем значения
-- Первая часть Location ID
set LocationID to item 1 of CSVParts
-- Вторая часть ECID
set ECID to item 2 of CSVParts
-- Третья часть DeviceType
set DeviceType to item 3 of CSVParts
-- Модифицируем полученные данные приводя в нужный вид
-- при использовании вывода инф. о девайсах в не текстовом формате, то используется целочисленное значение Location ID.
-- Конвертируем в HEX 1048576 -> 0x100000
set LocationID to "0x" & (do shell script "printf '%X' " & LocationID)
-- Выполняем проверку существования log-файла с именем ECID устройства,
-- Если лог есть, то считаем, что запускать повторно процедуру восстановления не нужно. Не добавляем в список результатов.
try
do shell script "test -f" & space & quoted form of (LogPath & ECID & ".log")
on error
-- Добавляем в результат
set end of ResultList to {DeviceType, ECID, LocationID}
end try
end repeat
return ResultList
--Пример вывода
--{{"iPhone8,4", "0x00480000000E4", "0x8320000"}, {"Macmini9,1", "0x928200000001E", "0x100000"}, {"iPhone7,2", "0x19610000000026", "0x8310000"}}
end cfgutilDevList
--===================
--===================
--Поиск подключенных устройств с помощью system_profiler
on systemprofilerDevList()
--Ищем среди подключенных USB устройств имена в которых содержится Mac, DFU или Recovery.
-- Отображаем найденные строки, а так же 5 и 8 строку для отображения Serial Number и
-- Location. Location необходим в качестве ключа для объединения со списком cfgutilDevList()
set SPUSBInfo to do shell script "system_profiler SPUSBDataType | awk '{
lines[NR] = $0
}
/Mac|DFU|Recovery/ {
mac_dfu_recovery_line = NR
mac_dfu_recovery_found = 1
}
/Serial Number:/ && mac_dfu_recovery_found && (NR == mac_dfu_recovery_line + 5) {
serial_number_line = NR
}
/Location ID:/ && mac_dfu_recovery_found && (NR == mac_dfu_recovery_line + 8) {
if ( mac_dfu_recovery_found && serial_number_line && NR == mac_dfu_recovery_line + 8) {
sub(/^ +/, \"\", lines[mac_dfu_recovery_line])
sub(/^ +/, \"\", lines[serial_number_line])
sub(/^ +/, \"\", $0)
print lines[mac_dfu_recovery_line]
print lines[serial_number_line]
print $0
mac_dfu_recovery_found = 0
serial_number_line = 0
}
}'"
-- Разделяем вывод на строки
set SPUSBInfoLines to paragraphs of SPUSBInfo
-- Пустой итоговый список
set ResultList to {}
-- Извлекаем значения проходя по каждой строке с шагом 3
repeat with i from 1 to count of SPUSBInfoLines by 3
set Model to item i of SPUSBInfoLines
set Serial to item (i + 1) of SPUSBInfoLines
set LocationID to item (i + 2) of SPUSBInfoLines
-- Модифицируем полученные данные приводя в нужный вид
--Последний символ строки ":", уберём это
set Model to text 1 thru -2 of Model
-- Серийный номер подключенный Mac отображается сразу, у iOS строка содержит множество другой информации
-- Mac загруженный в DFU режиме не отображает серийный номер.
set Serial to do shell script "echo" & space & Serial & space & "| awk '
/Serial Number:/ {
value = substr($0, index($0, \"Serial Number:\") + 15);
if (value ~ /^[0-9A-Za-z]{6,}/) {
print value;
found = 1;
}
}
/SRNM:/ {
split($0, arr, /SRNM:\\[|\\]/);
if (length(arr) > 1) {
print arr[2];
found = 1;
}
next;
}
END {
if (found == 0) print \"Serial Number not found\";
}
'"
(*
Меняем формат Location, т.к. у system_profiler и cfgutil они отличаются
Пример
0x00100000 --> 0x100000
0x08310000 --> 0x8310000
Поэтому конвертируем 0x08310000 в 8310000 и добавляем 0x спереди, чтобы значение соответствовало формату cfgutil
*)
--Вырезаем значение из строки
set LocationID to text 14 thru 23 of LocationID
set LocationID to "0x" & (do shell script "printf '%X' " & LocationID)
-- Добавляем инф о каждом устройстве в подсписке в список
set end of ResultList to {Model, Serial, LocationID}
end repeat
-- Выводим итоговый список
return ResultList
--Пример вывода
--{{"Mac mini", "H2000000Q6NY", "0x100000"}, {"Apple Mobile Device (Recovery Mode)", "DX6000000TVL", "0x8320000"}, {"Apple Mobile Device (Recovery Mode)", "F170000005MR", "0x8310000"}}
end systemprofilerDevList
--===================
-- Обработчик для объединения списков
--https://www.macscripter.net/t/merge-2-arrays-by-key/76457/10
--===================
on MergeLists(List1, List2)
-- Создаём пустой итоговый список
set ResultList to {}
-- Проходим по первому списку
repeat with subList1 in List1
-- Извлекаем значения подсписков из первого списка "List1" в подсписок "subList1"
set {Value01, Value02, ID01} to subList1
-- Проходим по второму списку
repeat with subList2 in List2
-- Извлекаем значения подсписков из второго списка "List2" в подсписок "subList2"
set {Value11, Value12, ID11} to subList2
--Если ключевые значения равны, создаём итоговый новый список
if ID01 is equal to ID11 then
set end of ResultList to {Value01, Value11, Value12, Value02, ID01}
exit repeat
end if
end repeat
end repeat
-- Выводим итоговый список
return ResultList
end MergeLists
--===================
\\
==== Создание LaunchDaemon ====
Создадим Launch Daemon
sudo nano /Library/LaunchDaemons/com.custom.restoredevices.plist
Label
custom.restoredevices
ProgramArguments
/usr/local/bin/cfgutil
exec
--on-attach
osascript '/Library/Application Support/Custom/RestoreDevices/Restore.scpt'
RunAtLoad
StandardErrorPath
/tmp/custom.restoredevices.stderr
StandardOutPath
/tmp/custom.restoredevices.stdout
Убедимся, что plist не содержит ошибок
plutil -lint /Library/LaunchDaemons/com.custom.restoredevices.plist
Загрузим и запустим демон
sudo launchctl bootstrap system/ /Library/LaunchDaemons/com.custom.restoredevices.plist
Убедимся, что демон запущен
sudo launchctl list | grep restoredev
Найдём процесс
ps aux | grep '[c]fgutil'
Launch Daemon работает независимо от входа пользователя и будет готов к работе сразу после загрузки macOS.
Если что-то пошло не так, то необработанные ошибки будут сохранятся в файле ''custom.restoredevices.stderr'', прочие информационные сообщения ''/tmp/custom.restoredevices.stdout''
При ненадёжном или медленном интернет соединении образы восстановления лучше скачать заблаговременно.
\\
==== Пример логирования процесса ====
Процесс восстановления логируется созданным обработчиком WriteLog(message, logFile), на выходе мы получаем примерно такие логи:
Восстановление iPhone
{{ apple-mac-os:macos-sequoia:iphone6-restore-log.png }}
Восстановление Mac
{{ apple-mac-os:macos-sequoia:macmini2020-restore-log.png }}
\\
==== Другой вариант получения списка подключенных устройств ====
Администраторы, которые уже погружались в автоматизацию Apple Configurator могут сказать, что лепнина из двух обработчиков systemprofilerDevList() и cfgutilDevList(), а затем ещё и объединение результатов обработчиком MergeLists() лишнее и ненужное звено, потому что производитель уже предоставил встроенные инструменты, получить информацию о подключенных устройствах можно так:
set DevicesList to my DevicesList()
on DevicesList()
try
tell script "Configuration Utility"
-- Вызываем необходимые свойства и присваиваем значения в переменные
copy CNFGvaluesOfSpecifiedProperties("all", {"deviceType", "ECID", "locationID", "serialNumber", "bootedState"}, false) to ¬
{deviceCount ¬
, propertyTitles ¬
, DeviceTypes ¬
, deviceECIDs ¬
, LocationIDs ¬
, serialNumbers ¬
, BootedStates}
end tell
-- Создаём пустой список
set ResultList to {}
--Получаем значения атрибутов для каждого устройства
repeat with i from 1 to the deviceCount
set DeviceType to item i of DeviceTypes
set ECID to item i of deviceECIDs
set LocationID to item i of LocationIDs
set SerialNumber to item i of serialNumbers -- серийные номера отображаются нас устройствах в статусе Booted. Серийные номер Mac не отображаются вообще.
set BootedState to item i of BootedStates -- значения Booted, Recovery или DFU
-- Изменим формат Location ID integer на hex
set LocationID to "0x" & (do shell script "printf '%X' " & LocationID)
-- Добавляем подсписки с значениями устройства в список
set end of ResultList to {DeviceType, ECID, LocationID, SerialNumber, BootedState}
end repeat
return ResultList
-- Пример вывода
-- {{"iPhone8,4", "0x0048FC93999E4", "0x8310000", missing value, "Recovery"}, {"Macmini9,1", "0x9282C1440801E", "0x100000", missing value, "DFU"}, {"iPhone7,2", "0x19610E38E81026", "0x8320000", "F17R30000000", "Booted"}}
on error
return false
end try
Мне не понравился нюанс, который не позволяет получить серийный номер подключенных Mac, а так же мобильных устройств в режиме DFU. Если этот момент вам неважен, то проще использовать обработчик CNFGvaluesOfSpecifiedProperties(), доступный из коробки.
Получить справку о наборе встроенных обработчиков для управления автоматизацией можно так:
tell script "Configuration Utility" to show help
Подводя итог, можно сказать, что подобная автоматизация может использоваться автономно, например, на стоящем на столе ИТ отдела Mac Mini 2020 года без монитора и периферии с торчащими Type-C кабелями для подключения как одиночных устройств, так и множества.
P.S.
В моём ведении нет сотен Mac, iPhone или iPad, которые нужно перезаливать перед выдачей, только несколько личных старых iPhone и Mac Mini которые можно перезаливать каждый день. Попробуйте этот вариант и сообщите в комментариях как это работает на множестве подключенных устройств.
----
Проверено на следующих конфигурациях:
^ Версия ОС ^
| Apple macOS Sequoia (15.0) |
----
{{:user:vyakob.png?50&nolink |}} Автор первичной редакции:\\ [[user:vyakob|Виталий Якоб]] \\ Время публикации: 05.12.2024 16:57
{{tag>Apple macOS Sequoia AppleScript iPhone iPad}}
~~DISCUSSION~~