Implement more of launchd, osinstaller, and managedsoftwareupdate

This commit is contained in:
Greg Neagle
2024-09-08 21:32:07 -07:00
parent 1e63b58eda
commit 516688516b
3 changed files with 345 additions and 3 deletions
@@ -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.")
}
}
}
+186
View File
@@ -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
}
}
+157 -1
View File
@@ -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)
}