Files
munki/code/cli/munki/shared/updatecheck/updatecheck.swift

564 lines
21 KiB
Swift

//
// updatecheck.swift
// munki
//
// Created by Greg Neagle on 8/26/24.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
private let display = DisplayAndLog.main
/// Download display icons for optional installs and active installs/removals
func downloadIconsForActiveItems(_ installInfo: PlistDict) {
var itemList = [PlistDict]()
for key in ["optional_installs", "managed_installs", "removals", "problem_items"] {
itemList += installInfo[key] as? [PlistDict] ?? []
}
if let stagedOSInstallerInfo = getStagedOSInstallerInfo() {
itemList.append(stagedOSInstallerInfo)
}
downloadIcons(itemList)
}
/// clean up cache dir
/// remove any item in the cache that isn't scheduled to be used for
/// an install or removal
/// this could happen if an item is downloaded on one updatecheck run,
/// but later removed from the manifest before it is installed or removed
/// -- so the cached itemis no longer needed.
func cleanUpDownloadCache(_ installInfo: PlistDict) {
let managedInstalls = installInfo["managed_installs"] as? [PlistDict] ?? []
let removals = installInfo["removals"] as? [PlistDict] ?? []
let problemItems = installInfo["problem_items"] as? [PlistDict] ?? []
let optionalInstalls = installInfo["optional_installs"] as? [PlistDict] ?? []
var keepList = managedInstalls.map {
$0["installer_item"] as? String ?? ""
}
keepList += removals.map {
$0["uninstaller_item"] as? String ?? ""
}.filter {
!$0.isEmpty
}
// Don't delete problem item partial downloads
keepList += problemItems.map {
$0["installer_item"] as? String ?? ""
}.filter {
!$0.isEmpty
}
// Don't delete optional installs that are designated as precache
keepList += optionalInstalls.filter {
$0["precache"] as? Bool ?? false
}.map {
baseName($0["installer_item_location"] as? String ?? "")
}.filter {
!$0.isEmpty
}
let cacheDir = managedInstallsDir(subpath: "Cache")
if let cacheNames = try? FileManager.default.contentsOfDirectory(atPath: cacheDir) {
for item in cacheNames {
let fullPath = (cacheDir as NSString).appendingPathComponent(item)
if item.hasSuffix(".download") {
// we have a partial download here
let simpleName = (item as NSString).deletingPathExtension
if cacheNames.contains(simpleName) {
// we have a partial and a full download
// for the same item. (This shouldn't happen.)
// remove the partial download.
display.detail("Removing partial download \(item) from cache")
try? FileManager.default.removeItem(atPath: fullPath)
} else if !keepList.contains(simpleName) {
// abandoned partial download
display.detail("Removing partial download \(item) from cache")
try? FileManager.default.removeItem(atPath: fullPath)
}
} else if !keepList.contains(item) {
display.detail("Removing \(item) from cache")
try? FileManager.default.removeItem(atPath: fullPath)
}
}
}
}
/// processes the LocalOnlyManifest if defined and present
func processLocalOnlyManifest(catalogList: [String], installInfo: inout PlistDict) async throws {
guard let localOnlyManifestName = stringPref("LocalOnlyManifest"),
!localOnlyManifestName.isEmpty,
!catalogList.isEmpty
else {
return
}
// if the manifest already exists, the name is being reused
if Manifests.shared.list().contains(localOnlyManifestName) {
display.error("LocalOnlyManifest \(localOnlyManifestName) has the same name as an existing manifest, skipping...")
return
}
let localOnlyManifestPath = managedInstallsDir(
subpath: "manifests/" + localOnlyManifestName)
if !pathExists(localOnlyManifestPath) {
display.debug1("LocalOnlyManifest \(localOnlyManifestName) is defined but is not present. Skipping.")
return
}
guard var localOnlyManifest = manifestData(localOnlyManifestPath) else {
display.error("Could not get manifest data from \(localOnlyManifestPath)")
return
}
Manifests.shared.set(localOnlyManifestName, path: localOnlyManifestPath)
// Finally ready to actually process it!
display.detail("**Processing local-only manifest**")
// remove catalogs, included_manifests, and conditional_items if present
for key in ["catalogs", "included_manifests", "conditional_items"] {
if localOnlyManifest[key] != nil {
display.warning("Local-only manifest \(localOnlyManifestName) contains section '\(key)`. Ignoring.")
localOnlyManifest[key] = nil
}
}
for key in ["managed_installs", "managed_uninstalls", "managed_updates", "optional_installs"] {
_ = try await processManifest(
localOnlyManifest,
forKey: key,
installInfo: &installInfo,
parentCatalogs: catalogList,
manifestName: "LocalOnlyManifest \(localOnlyManifestName)"
)
if stopRequested() {
return
}
}
}
/// processes the SelfServeManifest if present
func processSelfServeManifest(mainManifest: PlistDict, installInfo: inout PlistDict) async throws {
guard let parentCatalogs = mainManifest["catalogs"] as? [String] else {
display.error("Primary manifest has no catalogs")
return
}
// copy user-writable selfservice manifest if present
updateSelfServeManifest()
// process any default installs (adding to selfservice as needed)
_ = try await processManifest(
mainManifest,
forKey: "default_installs",
installInfo: &installInfo
)
let selfServeManifestPath = selfServiceManifestPath()
if !pathExists(selfServeManifestPath) {
// nothing to do!
return
}
guard let selfServeManifest = manifestData(selfServeManifestPath) else {
display.error("Selfserve manifest cannot be read!")
return
}
display.detail("**Processing self-serve choices**")
if var installs = selfServeManifest["managed_installs"] as? [String],
!installs.isEmpty
{
// build list of items in the optional_installs list
// that have not exceeded available seats
// and don't have notes (indicating why they can't be installed)
let installInfoOptionalInstalls = installInfo["optional_installs"] as? [PlistDict] ?? []
let availableOptionalInstalls = installInfoOptionalInstalls.filter {
$0["note"] == nil && ($0["licensed_seats_available"] as? Bool ?? true)
}.map {
$0["name"] as? String ?? ""
}.filter {
!$0.isEmpty
}
installs = installs.filter {
availableOptionalInstalls.contains($0)
}
for item in installs {
_ = await processInstall(
item,
catalogList: parentCatalogs,
installInfo: &installInfo,
isOptionalInstall: true
)
}
}
if let uninstalls = selfServeManifest["managed_uninstalls"] as? [String],
!uninstalls.isEmpty
{
for item in uninstalls {
_ = await processRemoval(
item,
catalogList: parentCatalogs,
installInfo: &installInfo
)
}
}
// update optional_installs with install/removal info
if var optionalInstalls = installInfo["optional_installs"] as? [PlistDict] {
let managedInstalls = installInfo["managed_installs"] as? [PlistDict] ?? []
let removals = installInfo["removals"] as? [PlistDict] ?? []
for (index, item) in optionalInstalls.enumerated() {
let installed = item["installed"] as? Bool ?? false
if !installed, itemInInstallInfo(item, theList: managedInstalls) {
optionalInstalls[index]["will_be_installed"] = true
} else if installed, itemInInstallInfo(item, theList: removals) {
optionalInstalls[index]["will_be_removed"] = true
}
}
installInfo["optional_installs"] = optionalInstalls
}
}
enum UpdateCheckResult: Int {
case checkDidntStart = -2
case finishedWithErrors = -1
case noUpdatesAvailable = 0
case updatesAvailable = 1
}
/// Checks for available new or updated managed software, downloading installer items if needed.
/// Returns UpdateCheckResult.
func checkForUpdates(clientID: String? = nil, localManifestPath: String? = nil) async throws -> UpdateCheckResult {
// Auto-detect a Munki repo if one isn't defined in preferences
autodetectRepoURLIfNeeded()
await Report.shared.record(getMachineFacts(), to: "MachineInfo")
// initialize our Munki keychain if we are using custom certs or CAs
let dummyKeychainObj = MunkiKeychain()
if DisplayOptions.munkistatusoutput {
munkiStatusActivate()
}
munkiLog("### Beginning managed software check ###")
display.majorStatus("Checking for available updates...")
munkiStatusPercent(-1)
munkiStatusDetail("")
var success = true
var mainManifestPath = ""
if let localManifestPath {
mainManifestPath = localManifestPath
} else {
do {
mainManifestPath = try getPrimaryManifest(alternateIdentifier: clientID)
} catch let err as ManifestError {
display.error("Could not retrieve managed install primary manifest: \(err.localizedDescription)")
throw err
}
}
guard let mainManifest = manifestData(mainManifestPath) else {
display.error("Could not get manifest data from main manifest \(mainManifestPath)")
return .finishedWithErrors
}
guard let mainManifestCatalogsList = mainManifest["catalogs"] as? [String],
!mainManifestCatalogsList.isEmpty
else {
display.error("Main manifest \(mainManifestPath) does not have a list of catalogs")
return .finishedWithErrors
}
if stopRequested() {
return .noUpdatesAvailable
}
// stop precaching_agent if it's running
stopPrecachingAgent()
// prevent idle sleep only if we are on AC power
var caffeinator: Caffeinator? = nil
if onACPower() {
caffeinator = Caffeinator(reason: "managedsoftwareupdate is checking for new software")
}
// initialize our installinfo record
var installInfo: PlistDict = [
"featured_items": [String](),
"managed_installs": [PlistDict](),
"managed_updates": [String](),
"optional_installs": [PlistDict](),
"problem_items": [PlistDict](),
"processed_installs": [String](),
"processed_uninstalls": [String](),
"removals": [PlistDict](),
]
// remove any staged os installer info we have; we'll check and
// recreate if still valid
removeStagedOSInstallerInfo()
do {
// check managed_installs
display.detail("**Checking for installs**")
_ = try await processManifest(
mainManifest,
forKey: "managed_installs",
installInfo: &installInfo
)
if stopRequested() {
return .noUpdatesAvailable
}
// reset progress indicator and detail field
munkiStatusMessage("Checking for additional changes...")
munkiStatusPercent(-1)
munkiStatusDetail("")
// check managed_uninstalls
display.detail("**Checking for removals**")
_ = try await processManifest(
mainManifest,
forKey: "managed_uninstalls",
installInfo: &installInfo
)
if stopRequested() {
return .noUpdatesAvailable
}
// now check for implicit removals
// use catalogs from main manifest
let autoremovalItems = getAutoRemovalItems(installInfo: installInfo, catalogList: mainManifestCatalogsList)
if !autoremovalItems.isEmpty {
display.detail("**Checking for implicit removals**")
for item in autoremovalItems {
if stopRequested() {
return .noUpdatesAvailable
}
_ = await processRemoval(
item,
catalogList: mainManifestCatalogsList,
installInfo: &installInfo
)
}
}
// process managed_updates
display.detail("**Checking for managed updates**")
_ = try await processManifest(
mainManifest,
forKey: "managed_updates",
installInfo: &installInfo
)
if stopRequested() {
return .noUpdatesAvailable
}
// process LocalOnlyManifest (if defined and present)
try await processLocalOnlyManifest(
catalogList: mainManifestCatalogsList, installInfo: &installInfo
)
if stopRequested() {
return .noUpdatesAvailable
}
// build list of optional installs
_ = try await processManifest(
mainManifest,
forKey: "optional_installs",
installInfo: &installInfo
)
if stopRequested() {
return .noUpdatesAvailable
}
// build list of featured installs
_ = try await processManifest(
mainManifest,
forKey: "featured_items",
installInfo: &installInfo
)
if stopRequested() {
return .noUpdatesAvailable
}
let inFeaturedItems = Set(installInfo["featured_items"] as? [String] ?? [])
let inOptionalInstalls = Set(
(installInfo["optional_installs"] as? [PlistDict] ?? []).map {
$0["name"] as? String ?? ""
}.filter {
!$0.isEmpty
}
)
for item in inFeaturedItems.subtracting(inOptionalInstalls) {
display.warning("\(item) is in the list of featured_items, but is not in the list of optional_installs. Will be ignored.")
}
// verify available license seats for optional installs
if let optionalInstalls = installInfo["optional_installs"] as? [PlistDict],
!optionalInstalls.isEmpty
{
installInfo["optional_installs"] = updateAvailableLicenseSeats(optionalInstalls)
}
// now process any self-serve choices
try await processSelfServeManifest(
mainManifest: mainManifest, installInfo: &installInfo
)
// filter managed_installs to get items already installed
var managedInstalls = installInfo["managed_installs"] as? [PlistDict] ?? []
let installedItems = managedInstalls.filter {
$0["installed"] as? Bool ?? false
}.map {
$0["name"] as? String ?? ""
}
// filter managed_installs to get problem items:
// not installed, but no installer item
let problemItems = managedInstalls.filter {
($0["installed"] as? Bool ?? true) == false &&
($0["installer_item"] as? String ?? "").isEmpty
}
// filter removals to get items already removed
// (or never installed)
let removals = installInfo["removals"] as? [PlistDict] ?? []
let removedItems = removals.filter {
($0["installed"] as? Bool ?? true) == false
}.map {
$0["name"] as? String ?? ""
}
// clean up any old managed_uninstalls in the SelfServeManifest
cleanUpSelfServeManagedUninstalls(removals)
// sort startosinstall items to the end of managed_installs
let nonStartOSInstallItems = managedInstalls.filter {
($0["install_type"] as? String ?? "") != "startosinstall"
}
let startOSInstallItems = managedInstalls.filter {
($0["install_type"] as? String ?? "") == "startosinstall"
}
managedInstalls = nonStartOSInstallItems + startOSInstallItems
installInfo["managed_installs"] = managedInstalls
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...
Report.shared.record(managedInstalls, to: "ManagedInstalls")
Report.shared.record(installedItems, to: "InstalledItems")
Report.shared.record(problemItems, to: "ProblemInstalls")
Report.shared.record(removedItems, to: "RemovedItems")
Report.shared.record(
installInfo["processed_installs"] as? [String] ?? [],
to: "managed_installs_list"
)
Report.shared.record(
installInfo["processed_uninstalls"] as? [String] ?? [],
to: "managed_uninstalls_list"
)
Report.shared.record(
installInfo["managed_updates"] as? [String] ?? [],
to: "managed_updates_list"
)
// now filter the managed_installs and removals lists
// so they have only items that need action
installInfo["managed_installs"] = managedInstalls.filter {
$0["installer_item"] != nil
}
installInfo["removals"] = removals.filter {
($0["installed"] as? Bool ?? false)
}
installInfo["problem_items"] = problemItems
// record the filtered lists
Report.shared.record(
installInfo["managed_installs"] as? [PlistDict] ?? [],
to: "ItemsToInstall"
)
Report.shared.record(
installInfo["removals"] as? [PlistDict] ?? [],
to: "ItemsToRemove"
)
// download display icons for optional installs
// and active installs/removals
downloadIconsForActiveItems(installInfo)
// get any custom client resources
downloadClientResources()
// record info object for conditional item comparisons
var conditions = await predicateInfoObject()
conditions["applications"] = nil
conditions["catalogs"] = mainManifestCatalogsList
Report.shared.record(conditions, to: "Conditions")
// Clean up some directories
cleanUpCatalogs()
cleanUpManifests()
cleanUpDownloadCache(installInfo)
// write out installList so our installer has the metadata needed
// for proper installs
var installInfoChanged = true
let installInfoPath = managedInstallsDir(subpath: "InstallInfo.plist")
var oldInstallInfo = PlistDict()
if pathExists(installInfoPath) {
do {
oldInstallInfo = try readPlist(fromFile: installInfoPath) as? PlistDict ?? PlistDict()
} catch {
display.error("Could not read InstallInfo.plist. Deleting...")
try? FileManager.default.removeItem(atPath: installInfoPath)
}
if (installInfo as NSDictionary).isEqual(to: oldInstallInfo as NSDictionary) {
installInfoChanged = false
display.detail("No change in InstallInfo.")
}
}
if installInfoChanged {
try writePlist(installInfo, toFile: installInfoPath)
}
} catch _ as ManifestError {
// we had an error with a manifest
// See if we have a valid InstallInfo from an earlier run
success = false
let installInfoPath = managedInstallsDir(subpath: "InstallInfo.plist")
var installInfo = PlistDict()
if let lastInstallInfo = (try? readPlist(fromFile: installInfoPath)) as? PlistDict {
installInfo = lastInstallInfo
}
let managedInstalls = installInfo["managed_installs"] as? [PlistDict] ?? []
let removals = installInfo["removals"] as? [PlistDict] ?? []
Report.shared.record(managedInstalls, to: "ItemsToInstall")
Report.shared.record(removals, to: "ItemsToRemove")
}
Report.shared.save()
munkiLog("### End managed software check ###")
var updateCount = (installInfo["managed_installs"] as? [PlistDict] ?? []).count
updateCount += (installInfo["removals"] as? [PlistDict] ?? []).count
// start our precaching agent
// note -- this must happen _after_ InstallInfo.plist gets written to disk.
startPrecachingAgent()
if !success {
return .finishedWithErrors
}
if updateCount > 0 {
return .updatesAvailable
}
return .noUpdatesAvailable
}