Merge branch 'Munki7dev'

This commit is contained in:
Greg Neagle
2025-12-02 09:38:09 -08:00
13 changed files with 169 additions and 146 deletions

View File

@@ -229,13 +229,14 @@ func notifyUserOfUpdates(force: Bool = false) {
if !force, activeDisplaySleepAssertion() {
// user may be in a virtual meeting or presenting.
// Skip the notification; hopefully we'll be able to notify later.
munkiLog("Skipping user notification.")
munkiLog("Skipping user notification because there is an active display sleep assertion.")
munkiLog("This may indicate the user is presenting or in a virtual meeting.")
return
}
// record current notification date
setPref("LastNotifiedDate", now)
munkiLog("Notifying user of available updates.")
munkiLog("LastNotifiedDate was \(lastNotifiedDate)")
munkiLog("LastNotifiedDate was \(RFC3339String(for: lastNotifiedDate))")
// trigger LaunchAgent to launch munki-notifier.app in the right context
let launchfile = "/var/run/com.googlecode.munki.munki-notifier"
FileManager.default.createFile(atPath: launchfile, contents: nil)
@@ -243,6 +244,9 @@ func notifyUserOfUpdates(force: Bool = false) {
// clear the trigger file. We have to do it because we're root,
// and the munki-notifier process is running as the user
try? FileManager.default.removeItem(atPath: launchfile)
} else {
munkiLog("Skipping user notification")
munkiLog("Last notification was \(RFC3339String(for: lastNotifiedDate)) and notification interval is \(daysBetweenNotifications) day(s).")
}
}
@@ -332,7 +336,6 @@ func doInstallTasks(doAppleUpdates: Bool = false, onlyUnattended: Bool = false)
}
var munkiItemsRestartAction = PostAction.none
//var appleItemsRestartAction = PostAction.none
if munkiUpdatesAvailable() > 0 {
// install Munki updates
@@ -353,7 +356,6 @@ func doInstallTasks(doAppleUpdates: Bool = false, onlyUnattended: Bool = false)
Report.shared.save()
//return max(appleItemsRestartAction, munkiItemsRestartAction) // we no longer support installing Apple updates
return munkiItemsRestartAction
}

View File

@@ -120,21 +120,6 @@ struct installedStateTests {
#expect(await installedState(item) == .thisVersionNotInstalled)
}
/*
/// If version_script something that isn't parseable as a version, return .thisVersionNotInstalled
@Test func installedStateWithVersionScriptInvalidOutputReturnsNotInstalled() async throws {
let item: PlistDict = [
"name": "Foo",
"version": "1.2.3",
"version_script": """
#!/bin/sh
echo "Foobarbaz"
"""
]
#expect(await installedState(item) == .thisVersionNotInstalled)
}
*/
/// If version_script exits non-zero, return .thisVersionNotInstalled
@Test func versionScriptErrorReturnsNotInstalled() async throws {
let item: PlistDict = [
@@ -147,6 +132,69 @@ struct installedStateTests {
]
#expect(await installedState(item) == .thisVersionNotInstalled)
}
/// If a receipt is not present, return .thisVersionNotInstalled
@Test func receiptNotPresentReturnsNotInstalled() async throws {
let item: PlistDict = [
"name": "Foo",
"version": "1.2.3",
"receipts": [
[
"packageid": "bar.doesntexist.foo",
"version": "1.2.3",
],
],
]
#expect(await installedState(item) == .thisVersionNotInstalled)
}
/// if receipt present and higher version, return .newerVersionInstalled
@Test func receiptPresentReturnsInstalled() async throws {
// this depends on a receipt installed by Apple that is present
// on macOS Sequoia and Tahoe, but might go away in the future...
let item: PlistDict = [
"name": "com.apple.files.data-template",
"version": "0.1",
"receipts": [
[
"packageid": "com.apple.files.data-template",
"version": "0.1",
],
],
]
#expect(await installedState(item) == .newerVersionInstalled)
}
/// if receipts array is empty (and no other installed criteria), return .thisVersionInstalled
@Test func emptyReceiptsArrayReturnsInstalled() async throws {
// If there's no way to determine what's installed,
// installedState() returns .thisVersionInstalled so that
// Munki does _not_ try to install
let item: PlistDict = [
"name": "Foo",
"version": "0.1",
"receipts": [],
]
#expect(await installedState(item) == .thisVersionInstalled)
}
/// if install array is defined but empty, ignore it and fall through to considering receipts
@Test func emptyInstallArrayAndNonexistentReceiptReturnsNotInstalled() async throws {
// If the installs array is empty, the check should fail though to
// use the receipts array, and should return .thisVersionNotInstalled
let item: PlistDict = [
"name": "Foo",
"version": "1.2.3",
"installs": [],
"receipts": [
[
"packageid": "bar.doesntexist.foo",
"version": "1.2.3",
],
],
]
#expect(await installedState(item) == .thisVersionNotInstalled)
}
}
struct someVersionInstalledTests {
@@ -263,6 +311,24 @@ struct someVersionInstalledTests {
]
#expect(await someVersionInstalled(item) == false)
}
/// if install array is defined but empty, ignore it and fall through to considering receipts
@Test func emptyInstallArrayAndNonexistentReceiptReturnsNotInstalled() async throws {
// If the installs array is empty, the check should fail though to
// use the receipts array, and should return false
let item: PlistDict = [
"name": "Foo",
"version": "1.2.3",
"installs": [],
"receipts": [
[
"packageid": "bar.doesntexist.foo",
"version": "1.2.3",
],
],
]
#expect(await someVersionInstalled(item) == false)
}
}
struct evidenceThisIsInstalledTests {
@@ -425,4 +491,22 @@ struct evidenceThisIsInstalledTests {
]
#expect(await evidenceThisIsInstalled(item) == true)
}
/// if install array is defined but empty, ignore it and fall through to considering receipts
@Test func emptyInstallArrayAndNonexistentReceiptReturnsNotInstalled() async throws {
// If the installs array is empty, the check should fail though to
// use the receipts array, and should return false
let item: PlistDict = [
"name": "Foo",
"version": "1.2.3",
"installs": [],
"receipts": [
[
"packageid": "bar.doesntexist.foo",
"version": "1.2.3",
],
],
]
#expect(await evidenceThisIsInstalled(item) == false)
}
}

View File

@@ -48,7 +48,6 @@ enum RestartAction: String, CaseIterable, ExpressibleByArgument {
/// Supported installer types for --installer-type argument
enum InstallerType: String, CaseIterable, ExpressibleByArgument {
case copy_from_dmg
case startosinstall
case stage_os_installer
}

View File

@@ -155,25 +155,9 @@ func createPkgInfoForDragNDrop(_ mountpoint: String, options: PkginfoOptions) th
guard !dragNDropItem.isEmpty else {
throw MunkiError("No application found on disk image.")
}
// check to see if item is a macOS installer and we can generate a startosinstall item
// TODO: remove this or print warning
// since it looks like Munki 7 won't support this installer_type
let itempath = (mountpoint as NSString).appendingPathComponent(dragNDropItem)
let itemIsInstallMacOSApp = pathIsInstallMacOSApp(itempath)
if itemIsInstallMacOSApp,
options.type.installerType == .startosinstall
{
if options.hidden.printWarnings,
installMacOSAppIsStub(itempath)
{
printStderr("WARNING: the provided disk image appears to contain an Install macOS application, but the application does not contain Contents/SharedSupport/InstallESD.dmg or Contents/SharedSupport/SharedSupport.dmg")
}
return try makeStartOSInstallPkgInfo(
mountpoint: mountpoint, item: dragNDropItem
)
}
// continue as copy_from_dmg item
let itempath = (mountpoint as NSString).appendingPathComponent(dragNDropItem)
installsitem = createInstallsItem(itempath)
if !installsitem.isEmpty {
@@ -234,7 +218,7 @@ func createPkgInfoForDragNDrop(_ mountpoint: String, options: PkginfoOptions) th
info["uninstall_method"] = "remove_copied_items"
// Should we add extra info for a stage_os_installer item?
if itemIsInstallMacOSApp,
if pathIsInstallMacOSApp(itempath),
options.type.installerType == nil || options.type.installerType == .stage_os_installer
{
let additionalInfo = try makeStageOSInstallerPkgInfo(itempath)
@@ -310,11 +294,12 @@ func createPkgInfoFromDmg(_ dmgpath: String,
/// Attempt to read a file with the same name as the input string and return its text,
/// otherwise return the input string
func readFileOrString(_ fileNameOrString: String) -> String {
if !pathExists(fileNameOrString) {
func readFileOrString(_ fileNameOrString: String) throws -> String {
let expandedPath = (fileNameOrString as NSString).expandingTildeInPath
if !pathExists(expandedPath) {
return fileNameOrString
}
return (try? String(contentsOfFile: fileNameOrString, encoding: .utf8)) ?? fileNameOrString
return try fileContents(fileNameOrString)
}
/// If path appears to be inside the repo's pkgs directory, return a path relative to the pkgs dir
@@ -359,6 +344,16 @@ func getMinimumOSVersionFromInstallsApps(_ pkginfo: PlistDict) -> String? {
return minimumOSVersions.max()?.value
}
/// return contents of file at path, expanding tilde as needed
func fileContents(_ path: String) throws -> String {
let expandedPath = (path as NSString).expandingTildeInPath
do {
return try String(contentsOfFile: expandedPath, encoding: .utf8)
} catch {
throw MunkiError("Failed to read file \(expandedPath): \(error)")
}
}
/// Return a pkginfo dictionary for installeritem
func makepkginfo(_ filepath: String?,
options: PkginfoOptions) throws -> PlistDict
@@ -448,7 +443,7 @@ func makepkginfo(_ filepath: String?,
pkginfo["catalogs"] = options.other.catalog
}
if let description = options.override.description {
pkginfo["description"] = readFileOrString(description)
pkginfo["description"] = try readFileOrString(description)
}
if let displayname = options.override.displayname {
pkginfo["display_name"] = displayname
@@ -494,46 +489,30 @@ func makepkginfo(_ filepath: String?,
// add pkginfo scripts if specified
// TODO: verify scripts start with a shebang line?
if let installcheckScript = options.script.installcheckScript {
if let scriptText = try? String(contentsOfFile: installcheckScript, encoding: .utf8) {
pkginfo["installcheck_script"] = scriptText
}
pkginfo["installcheck_script"] = try fileContents(installcheckScript)
}
if let uninstallcheckScript = options.script.uninstallcheckScript {
if let scriptText = try? String(contentsOfFile: uninstallcheckScript, encoding: .utf8) {
pkginfo["uninstallcheck_script"] = scriptText
}
pkginfo["uninstallcheck_script"] = try fileContents(uninstallcheckScript)
}
if let postinstallScript = options.script.postinstallScript {
if let scriptText = try? String(contentsOfFile: postinstallScript, encoding: .utf8) {
pkginfo["postinstall_script"] = scriptText
}
pkginfo["postinstall_script"] = try fileContents(postinstallScript)
}
if let preinstallScript = options.script.preinstallScript {
if let scriptText = try? String(contentsOfFile: preinstallScript, encoding: .utf8) {
pkginfo["preinstall_script"] = scriptText
}
pkginfo["preinstall_script"] = try fileContents(preinstallScript)
}
if let postuninstallScript = options.script.postuninstallScript {
if let scriptText = try? String(contentsOfFile: postuninstallScript, encoding: .utf8) {
pkginfo["postuninstall_script"] = scriptText
}
pkginfo["postuninstall_script"] = try fileContents(postuninstallScript)
}
if let preuninstallScript = options.script.preuninstallScript {
if let scriptText = try? String(contentsOfFile: preuninstallScript, encoding: .utf8) {
pkginfo["preuninstall_script"] = scriptText
}
pkginfo["preuninstall_script"] = try fileContents(preuninstallScript)
}
if let uninstallScript = options.script.uninstallScript {
if let scriptText = try? String(contentsOfFile: uninstallScript, encoding: .utf8) {
pkginfo["uninstall_script"] = scriptText
pkginfo["uninstall_method"] = "uninstall_script"
pkginfo["uninstallable"] = true
}
pkginfo["uninstall_script"] = try fileContents(uninstallScript)
pkginfo["uninstall_method"] = "uninstall_script"
pkginfo["uninstallable"] = true
}
if let versionScript = options.script.versionScript {
if let scriptText = try? String(contentsOfFile: versionScript, encoding: .utf8) {
pkginfo["version_script"] = scriptText
}
pkginfo["version_script"] = try fileContents(versionScript)
}
// more options and pkginfo bits
if !installeritem.isEmpty || options.type.nopkg {
@@ -588,7 +567,7 @@ func makepkginfo(_ filepath: String?,
pkginfo["installer_environment"] = options.pkg.installerEnvironmentDict
}
if let notes = options.other.notes {
pkginfo["notes"] = readFileOrString(notes)
pkginfo["notes"] = try readFileOrString(notes)
}
return pkginfo

View File

@@ -224,7 +224,9 @@ func installItem(_ item: PlistDict) async -> (Int, Bool) {
needToRestart = requiresRestart(item)
default:
// unknown or no longer supported installer type
if ["appdmg", "profiles"].contains(installerType) || installerType.hasPrefix("Adobe") {
if ["appdmg", "apple_update_metadata", "startosinstall", "profile"].contains(installerType) ||
installerType.hasPrefix("Adobe")
{
display.error("Installer type '\(installerType)' for \(installerItem) is no longer supported.")
} else {
display.error("Installer type '\(installerType)' for \(installerItem) is an unknown installer type.")
@@ -266,7 +268,7 @@ func installWithInstallInfo(
if installerType == "startosinstall" {
skippedInstalls.append(item)
display.debug1("Skipping install of \(itemName) because it's a startosinstall item. Will install later.")
display.debug1("Skipping install of \(itemName) because it's a startosinstall item, which is no longer supported.")
continue
}
if onlyUnattended {

View File

@@ -44,17 +44,6 @@ func findInstallMacOSApp(_ dirpath: String) -> String? {
return nil
}
/// Some downloaded macOS installer apps are stubs that don't contain
/// all the needed resources, which are later downloaded when the app is run
/// we can't use those
func installMacOSAppIsStub(_ apppath: String) -> Bool {
let installESDdmg = (apppath as NSString).appendingPathComponent("Contents/SharedSupport/InstallESD.dmg")
let sharedSupportDmg = (apppath as NSString).appendingPathComponent("Contents/SharedSupport/SharedSupport.dmg")
let filemanager = FileManager.default
return !(filemanager.fileExists(atPath: installESDdmg) ||
filemanager.fileExists(atPath: sharedSupportDmg))
}
/// Returns info parsed out of OS Installer app
func getInfoFromInstallMacOSApp(_ appPath: String) throws -> PlistDict {
var appInfo = PlistDict()
@@ -128,62 +117,6 @@ func generateInstallableCondition(_ models: [String]) -> String {
return predicates.joined(separator: " OR ")
}
/// Returns pkginfo for a macOS installer on a disk image, using the startosinstall installation method
func makeStartOSInstallPkgInfo(mountpoint: String, item: String) throws -> PlistDict {
let appPath = (mountpoint as NSString).appendingPathComponent(item)
guard pathIsInstallMacOSApp(appPath) else {
throw MunkiError("Disk image item \(item) doesn't appear to be a macOS installer app")
}
let appName = (item as NSString).lastPathComponent
let appInfo = try getInfoFromInstallMacOSApp(appPath)
guard let version = appInfo["version"] as? String else {
throw MunkiError("Could not parse version from \(item)")
}
let displayName = (appName as NSString).deletingPathExtension
let munkiItemName = displayName.replacingOccurrences(of: " ", with: "_")
let description = "Installs macOS version \(version)"
var installedSize = Int(18.5 * 1024 * 1024)
var minimumMunkiVersion = "3.6.3"
let minimumOSVersion = "10.9"
if version.hasPrefix("10.14") {
// https://support.apple.com/en-us/HT201475
// use initial values
} else if version.hasPrefix("11.") {
// https://support.apple.com/en-us/HT211238
installedSize = Int(35.5 * 1024 * 1024)
minimumMunkiVersion = "5.1.0"
} else if version.hasPrefix("12.") {
// https://support.apple.com/en-us/HT212551
installedSize = Int(26 * 1024 * 1024)
minimumMunkiVersion = "5.1.0"
} else {
// no published guidance from Apple, just use same as Monterey
installedSize = Int(26 * 1024 * 1024)
minimumMunkiVersion = "5.1.0"
}
var pkginfo: PlistDict
pkginfo = [
"RestartAction": "RequireRestart",
"apple_item": true,
"description": description,
"display_name": displayName,
"installed_size": installedSize,
"installer_type": "startosinstall",
"minimum_munki_version": minimumMunkiVersion,
"minimum_os_version": minimumOSVersion,
"name": munkiItemName,
"supported_architectures": ["x86_64"],
"uninstallable": false,
"version": version,
]
if let models = appInfo["SupportedDeviceModels"] as? [String] {
pkginfo["installable_condition_disabled"] = generateInstallableCondition(models)
}
return pkginfo
}
/// Returns additional pkginfo from macOS installer at app_path,
/// describing a stage_os_installer item
func makeStageOSInstallerPkgInfo(_ appPath: String) throws -> PlistDict {

View File

@@ -256,7 +256,7 @@ func processInstall(
if installedState == .thisVersionNotInstalled {
if !dependenciesMet {
// we should not attempt to install
display.warning("Didn't attempt ro install \(manifestItemName) because could not resolve all dependencies.")
display.warning("Didn't attempt to install \(manifestItemName) because could not resolve all dependencies.")
// add information to managed_installs so we have some feedback
// to display in MSC.app
processedItem["installed"] = false

View File

@@ -207,10 +207,11 @@ func downloadIcons(_ itemList: [PlistDict]) {
var iconsToKeep = [String]()
let iconsDir = managedInstallsDir(subpath: "icons")
let iconHashes = getIconHashes()
let supportedIconExtensions = ["bmp", "gif", "icns", "jpg", "jpeg", "png", "psd", "tga", "tif", "tiff", "yuv"]
for item in itemList {
var iconName = item["icon_name"] as? String ?? item["name"] as? String ?? "<unknown>"
if (iconName as NSString).pathExtension.isEmpty {
if !supportedIconExtensions.contains((iconName as NSString).pathExtension) {
iconName += ".png"
}
iconsToKeep.append(iconName)

View File

@@ -128,7 +128,9 @@ func installedState(_ pkginfo: PlistDict) async -> InstallationState {
return .thisVersionInstalled
}
// do we have installs items?
if let installItems = pkginfo["installs"] as? [PlistDict] {
if let installItems = pkginfo["installs"] as? [PlistDict],
!installItems.isEmpty
{
for item in installItems {
do {
let compareResult = try compareItem(item)
@@ -145,6 +147,8 @@ func installedState(_ pkginfo: PlistDict) async -> InstallationState {
}
}
} else if let receipts = pkginfo["receipts"] as? [PlistDict] {
// if there are no 'installs' items, then we'll use receipt info
// to determine install status.
for item in receipts {
do {
let compareResult = try await compareReceipt(item)
@@ -209,7 +213,9 @@ func someVersionInstalled(_ pkginfo: PlistDict) async -> Bool {
return true
}
// do we have installs items?
if let installItems = pkginfo["installs"] as? [PlistDict] {
if let installItems = pkginfo["installs"] as? [PlistDict],
!installItems.isEmpty
{
for item in installItems {
do {
let compareResult = try compareItem(item)
@@ -300,6 +306,7 @@ func evidenceThisIsInstalled(_ pkginfo: PlistDict) async -> Bool {
}
var foundAllInstallItems = false
if let installItems = pkginfo["installs"] as? [PlistDict],
!installItems.isEmpty,
(pkginfo["uninstall_method"] as? String ?? "") != "removepackages"
{
display.debug2("Checking 'installs' items...")

View File

@@ -447,8 +447,8 @@ func checkForUpdates(clientID: String? = nil, localManifestPath: String? = nil)
managedInstalls = nonStartOSInstallItems + startOSInstallItems
installInfo["managed_installs"] = managedInstalls
if startOSInstallItems.count > 1 {
display.warning("There are multiple startosinstall items in managed_installs. Only the install of the first one will be attempted.")
if startOSInstallItems.count > 0 {
display.warning("There are startosinstall items in managed_installs. This type of install is no longer supported.")
}
// record detail before we throw it away...

View File

@@ -211,7 +211,7 @@ func runCLI(_ tool: String,
let task = Process()
task.executableURL = URL(fileURLWithPath: tool)
task.arguments = arguments
if !environment.isEmpty == false {
if !environment.isEmpty {
task.environment = environment
}

View File

@@ -53,3 +53,19 @@ func addTZOffsetToDate(_ date: Date) -> Date {
// return new Date plus the offset
return Date(timeInterval: secondsOffset, since: date)
}
/// Returns an ISO 8601-formatted string in UTC for given date
func ISO8601String(for date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter.string(from: date)
}
/// Retutns an RFC 3339-formatted string in the current time zone for given date
func RFC3339String(for date: Date) -> String {
// RFC 3339 date format like `2024-07-01 17:30:32-08:00`
let formatter = ISO8601DateFormatter()
formatter.timeZone = TimeZone.current
formatter.formatOptions = [.withInternetDateTime, .withSpaceBetweenDateAndTime]
return formatter.string(from: date)
}

View File

@@ -19,7 +19,7 @@
// limitations under the License.
/// one single place to define a version for CLI tools
let CLI_TOOLS_VERSION = "7.0.3"
let CLI_TOOLS_VERSION = "7.0.4"
let BUILD = "<BUILD_GOES_HERE>"
/// Returns version of Munki tools