mirror of
https://github.com/munki/munki.git
synced 2026-05-24 07:08:39 -05:00
Implement more of launchd, osinstaller, and managedsoftwareupdate
This commit is contained in:
@@ -323,10 +323,10 @@ struct ManagedSoftwareUpdate: AsyncParsableCommand {
|
||||
munkiUpdateCount = 0
|
||||
appleUpdateCount = 0
|
||||
if getStagedOSInstallerInfo() != nil {
|
||||
// TODO: implement osinstaller.launch()
|
||||
_ = launchStagedOSInstaller()
|
||||
} else {
|
||||
// staged OS installer is missing
|
||||
displayError("Requested to launch staged OS installer, but no info on a staged installer was found.")
|
||||
displayError("Requested to launch staged OS installer, but no info on a staged OS installer was found.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,3 +57,189 @@ func getSocketFd(_ socketName: String) throws -> [CInt] {
|
||||
)
|
||||
return [CInt](outputFds)
|
||||
}
|
||||
|
||||
enum LaunchdJobState {
|
||||
case unknown
|
||||
case stopped
|
||||
case running
|
||||
}
|
||||
|
||||
struct LaunchdJobInfo {
|
||||
var state: LaunchdJobState
|
||||
var pid: Int?
|
||||
var lastExitStatus: Int?
|
||||
}
|
||||
|
||||
func launchdJobInfo(_ jobLabel: String) -> LaunchdJobInfo {
|
||||
/// Get info about a launchd job. Returns LaunchdJobInfo.
|
||||
var info = LaunchdJobInfo(
|
||||
state: .unknown,
|
||||
pid: nil,
|
||||
lastExitStatus: nil
|
||||
)
|
||||
let result = runCLI("/bin/launchctl", arguments: ["list"])
|
||||
if result.exitcode != 0 || result.output.isEmpty {
|
||||
return info
|
||||
}
|
||||
let lines = result.output.components(separatedBy: .newlines)
|
||||
let jobLines = lines.filter {
|
||||
$0.hasSuffix("\t\(jobLabel)")
|
||||
}
|
||||
if jobLines.count != 1 {
|
||||
// unexpected number of lines matched our label
|
||||
return info
|
||||
}
|
||||
let infoParts = jobLines[0].components(separatedBy: "\t")
|
||||
if infoParts.count != 3 {
|
||||
// unexpected number of fields in the line
|
||||
return info
|
||||
}
|
||||
if infoParts[0] == "-" {
|
||||
info.pid = nil
|
||||
info.state = .stopped
|
||||
} else {
|
||||
info.pid = Int(infoParts[0])
|
||||
info.state = .running
|
||||
}
|
||||
if infoParts[1] == "-" {
|
||||
info.lastExitStatus = nil
|
||||
} else {
|
||||
info.lastExitStatus = Int(infoParts[1])
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func stopLaunchdJob(_ jobLabel: String) throws {
|
||||
/// Stop a launchd job
|
||||
let result = runCLI("/bin/launchctl", arguments: ["stop", jobLabel])
|
||||
if result.exitcode != 0 {
|
||||
throw MunkiError("launchctl stop error \(result.exitcode): \(result.error)")
|
||||
}
|
||||
}
|
||||
|
||||
func removeLaunchdJob(_ jobLabel: String) throws {
|
||||
/// Remove a launchd job by label
|
||||
let result = runCLI("/bin/launchctl", arguments: ["remove", jobLabel])
|
||||
if result.exitcode != 0 {
|
||||
throw MunkiError("launchctl remove error \(result.exitcode): \(result.error)")
|
||||
}
|
||||
}
|
||||
|
||||
class LaunchdJob {
|
||||
/// launchd job object
|
||||
|
||||
var label: String
|
||||
var cleanUpAtExit: Bool
|
||||
var stdout: FileHandle?
|
||||
var stderr: FileHandle?
|
||||
var stdOutPath: String
|
||||
var stdErrPath: String
|
||||
var plist: PlistDict
|
||||
var plistPath: String
|
||||
|
||||
init(
|
||||
cmd: [String],
|
||||
environmentVars: [String: String]? = nil,
|
||||
jobLabel: String? = nil,
|
||||
cleanUpAtExit: Bool = true
|
||||
) throws {
|
||||
// Initialize our launchd job
|
||||
var tmpdir = TempDir.shared.path
|
||||
if !cleanUpAtExit {
|
||||
// need to use a different tmpdir than the shared one,
|
||||
// which will get cleaned up when managedsoftwareupdate
|
||||
// exits
|
||||
tmpdir = TempDir().path
|
||||
}
|
||||
guard let tmpdir else {
|
||||
throw MunkiError("Could not allocate temp dir for launchd job")
|
||||
}
|
||||
// label this job
|
||||
label = jobLabel ?? "com.googlecode.munki." + UUID().uuidString
|
||||
self.cleanUpAtExit = cleanUpAtExit
|
||||
stdOutPath = (tmpdir as NSString).appendingPathComponent(label + ".stdout")
|
||||
stdErrPath = (tmpdir as NSString).appendingPathComponent(label + ".stderr")
|
||||
plistPath = (tmpdir as NSString).appendingPathComponent(label + ".plist")
|
||||
plist = [
|
||||
"Label": label,
|
||||
"ProgramArguments": cmd,
|
||||
"StandardOutPath": stdOutPath,
|
||||
"StandardErrorPath": stdErrPath,
|
||||
]
|
||||
if let environmentVars {
|
||||
plist["EnvironmentVariables"] = environmentVars
|
||||
}
|
||||
// create stdout and stderr files
|
||||
guard FileManager.default.createFile(atPath: stdOutPath, contents: nil),
|
||||
FileManager.default.createFile(atPath: stdErrPath, contents: nil)
|
||||
else {
|
||||
throw MunkiError("Could not create stdout/stderr files for launchd job \(label)")
|
||||
}
|
||||
// write out launchd plist
|
||||
do {
|
||||
try writePlist(plist, toFile: plistPath)
|
||||
// set owner, group and mode to those required
|
||||
// by launchd
|
||||
try FileManager.default.setAttributes(
|
||||
[.ownerAccountID: 0,
|
||||
.groupOwnerAccountID: 0,
|
||||
.posixPermissions: 0o644],
|
||||
ofItemAtPath: plistPath
|
||||
)
|
||||
} catch {
|
||||
throw MunkiError("Could not create plist for launchd job \(label): \(error.localizedDescription)")
|
||||
}
|
||||
// load the job
|
||||
let result = runCLI("/bin/launchctl", arguments: ["load", plistPath])
|
||||
if result.exitcode != 0 {
|
||||
throw MunkiError("launchctl load error for \(label): \(result.exitcode): \(result.error)")
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
/// Attempt to clean up
|
||||
if cleanUpAtExit {
|
||||
if !plistPath.isEmpty {
|
||||
_ = runCLI("/bin/launchctl", arguments: ["unload", plistPath])
|
||||
}
|
||||
try? stdout?.close()
|
||||
try? stderr?.close()
|
||||
let fm = FileManager.default
|
||||
try? fm.removeItem(atPath: plistPath)
|
||||
try? fm.removeItem(atPath: stdOutPath)
|
||||
try? fm.removeItem(atPath: stdErrPath)
|
||||
}
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
/// Start the launchd job
|
||||
let result = runCLI("/bin/launchctl", arguments: ["start", label])
|
||||
if result.exitcode != 0 {
|
||||
throw MunkiError("Could not start launchd job \(label): \(result.error)")
|
||||
}
|
||||
// open the stdout and stderr output files and
|
||||
// store their file handles for use
|
||||
stdout = FileHandle(forReadingAtPath: stdOutPath)
|
||||
stderr = FileHandle(forReadingAtPath: stdErrPath)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
/// Stop the launchd job
|
||||
try? stopLaunchdJob(label)
|
||||
}
|
||||
|
||||
func info() -> LaunchdJobInfo {
|
||||
/// Get info about the launchd job.
|
||||
return launchdJobInfo(label)
|
||||
}
|
||||
|
||||
func exitcode() -> Int? {
|
||||
/// Returns the process exit code, if the job has exited; otherwise,
|
||||
/// returns nil
|
||||
let info = info()
|
||||
if info.state == .stopped {
|
||||
return info.lastExitStatus
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,4 +388,160 @@ func userIsVolumeOwner(_ username: String) -> Bool {
|
||||
return volumeOwnerUUIDs().contains(getGeneratedUID(username))
|
||||
}
|
||||
|
||||
// TODO: implement functions for launching staged macOS installer
|
||||
// functions for launching staged macOS installer
|
||||
|
||||
func getAdminOpenPath() -> String? {
|
||||
/// Writes our adminopen script to a temp file. Returns the path.
|
||||
let scriptText = """
|
||||
#!/bin/bash
|
||||
|
||||
# This script is designed to be run as root.
|
||||
# It takes one argument, a path to an app to be launched.
|
||||
#
|
||||
# If the current console user is not a member of the admin group, the user will
|
||||
# be added to to the group.
|
||||
# The app will then be launched in the console user's context.
|
||||
# When the app exits (or this script is killed via SIGINT or SIGTERM),
|
||||
# if we had promoted the user to admin, we demote that user once again.
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
|
||||
function fail {
|
||||
echo "$@" 1>&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
function demote_user {
|
||||
# demote CONSOLEUSER from admin
|
||||
dseditgroup -o edit -d ${CONSOLEUSER} -t user admin
|
||||
}
|
||||
|
||||
if [ $EUID -ne 0 ]; then
|
||||
fail "This script must be run as root."
|
||||
fi
|
||||
|
||||
|
||||
CONSOLEUSER=$(stat -f %Su /dev/console)
|
||||
if [ "${CONSOLEUSER}" == "root" ] ; then
|
||||
fail "The console user may not be root!"
|
||||
fi
|
||||
|
||||
USER_UID=$(id -u ${CONSOLEUSER})
|
||||
if [ $? -ne 0 ] ; then
|
||||
# failed to get UID, bail
|
||||
fail "Could not get UID for ${CONSOLEUSER}"
|
||||
fi
|
||||
|
||||
APP=$1
|
||||
if [ "${APP}" == "" ] ; then
|
||||
# no application specified
|
||||
fail "Need to specify an application!"
|
||||
fi
|
||||
|
||||
# check if CONSOLEUSER is admin
|
||||
dseditgroup -o checkmember -m ${CONSOLEUSER} admin > /dev/null
|
||||
if [ $? -ne 0 ] ; then
|
||||
# not currently admin, so promote to admin
|
||||
dseditgroup -o edit -a ${CONSOLEUSER} -t user admin
|
||||
# make sure we demote the user at the end or if we are interrupted
|
||||
trap demote_user EXIT SIGINT SIGTERM
|
||||
fi
|
||||
|
||||
# launch $APP as $USER_UID and wait until it exits
|
||||
launchctl asuser ${USER_UID} open -W "${APP}"
|
||||
|
||||
"""
|
||||
guard let tempdir = TempDir.shared.path else {
|
||||
displayError("Could not get temp directory for adminopen tool")
|
||||
return nil
|
||||
}
|
||||
let scriptPath = (tempdir as NSString).appendingPathComponent("adminopen")
|
||||
guard createExecutableFile(
|
||||
atPath: scriptPath,
|
||||
withStringContents: scriptText,
|
||||
posixPermissions: 0o744
|
||||
)
|
||||
else {
|
||||
displayError("Could not get temp directory for adminopen tool")
|
||||
return nil
|
||||
}
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
func launchInstallerApp(_ appPath: String) -> Bool {
|
||||
/// Runs our adminopen tool to launch the Install macOS app. adminopen is run
|
||||
/// via launchd so we can exit after the app is launched (and the user may or
|
||||
/// may not actually complete running it.) Returns true if we run adminopen,
|
||||
/// false otherwise (some reasons: can't find Install app, no GUI user)
|
||||
|
||||
// do we have a GUI user?
|
||||
let username = getConsoleUser()
|
||||
if username.isEmpty || username == "loginwindow" {
|
||||
// we're at the loginwindow. Bail.
|
||||
displayError("Could not launch macOS installer application: No current GUI user.")
|
||||
return false
|
||||
}
|
||||
|
||||
// if we're on Apple silicon -- is the user a volume owner?
|
||||
if isAppleSilicon(), !userIsVolumeOwner(username) {
|
||||
displayError("Could not launch macOS installer application: Current GUI user \(username) is not a volume owner.")
|
||||
return false
|
||||
}
|
||||
|
||||
// create the adminopen tool and get its path
|
||||
guard let adminOpenPath = getAdminOpenPath() else {
|
||||
displayError("Error launching macOS installer: Can't create adminopen tool.")
|
||||
return false
|
||||
}
|
||||
|
||||
// make sure the Install macOS app is present
|
||||
if !pathExists(appPath) {
|
||||
displayError("Error launching macOS installer: \(appPath) doesn't exist.")
|
||||
return false
|
||||
}
|
||||
|
||||
// OK, all preconditions are met, let's go!
|
||||
displayMajorStatus("Launching macOS installer...")
|
||||
let cmd = [adminOpenPath, appPath]
|
||||
do {
|
||||
let job = try LaunchdJob(cmd: cmd, cleanUpAtExit: false)
|
||||
try job.start()
|
||||
// sleep a bit, then check to see if our launchd job has exited with an error
|
||||
usleep(1_000_000)
|
||||
if let exitcode = job.exitcode(), exitcode != 0 {
|
||||
var errorMsg = ""
|
||||
if let stderr = job.stderr {
|
||||
errorMsg = String(data: stderr.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
}
|
||||
throw MunkiError("(\(exitcode)) \(errorMsg)")
|
||||
}
|
||||
} catch {
|
||||
displayError("Failed to launch macOS installer due to launchd error.")
|
||||
displayError(error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
|
||||
// set Munki to run at boot after the OS upgrade is complete
|
||||
do {
|
||||
try setBootstrapMode()
|
||||
} catch {
|
||||
displayWarning("Could not set up Munki to run at boot after OS upgrade is complete: \(error.localizedDescription)")
|
||||
}
|
||||
// return true to indicate we launched the Install macOS app
|
||||
return true
|
||||
}
|
||||
|
||||
func launchStagedOSInstaller() -> Bool {
|
||||
/// Attempt to launch a staged OS installer
|
||||
guard let osInstallerInfo = getStagedOSInstallerInfo(),
|
||||
let osInstallerPath = osInstallerInfo["osinstaller_path"] as? String
|
||||
else {
|
||||
displayError("Could not get path to staged OS installer.")
|
||||
return false
|
||||
}
|
||||
if boolPref("SuppressStopButtonOnInstall") ?? false {
|
||||
munkiStatusHideStopButton()
|
||||
}
|
||||
munkiLog("### Beginning GUI launch of macOS installer ###")
|
||||
return launchInstallerApp(osInstallerPath)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user