mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
Merge pull request #7881 from utmapp/feature/apple-guest-provisioning
scripting: provision macOS guests on first boot
This commit is contained in:
@@ -72,8 +72,28 @@
|
||||
<parameter name="recovery" code="ReCo" description="When true, start the VM in recovery mode. Default value is false." type="boolean" optional="yes">
|
||||
<cocoa key="bootRecoveryFlag"/>
|
||||
</parameter>
|
||||
<parameter name="provisioning" code="GpOp" description="Set up a macOS guest without user interaction. Only applies to the first start after macOS is installed and is never saved. Requires the Apple backend, macOS 27 or later, and a guest with macOS 27 or later." type="guest provisioning" optional="yes">
|
||||
<cocoa key="provisioningOptions"/>
|
||||
</parameter>
|
||||
</command>
|
||||
|
||||
<record-type name="guest provisioning" code="GpCf" description="Account to create when setting up a macOS guest.">
|
||||
<property name="full name" code="GpFn" type="text"
|
||||
description="Full name of the account. Defaults to the username."/>
|
||||
|
||||
<property name="username" code="GpUs" type="text"
|
||||
description="Short name of the account."/>
|
||||
|
||||
<property name="password" code="GpPw" type="text"
|
||||
description="Password of the account."/>
|
||||
|
||||
<property name="automatic login" code="GpAl" type="boolean"
|
||||
description="Log in to the account automatically when the guest starts up. Default value is false."/>
|
||||
|
||||
<property name="remote login" code="GpRl" type="boolean"
|
||||
description="Turn on Remote Login (SSH) in the guest. Default value is false."/>
|
||||
</record-type>
|
||||
|
||||
<command name="install" code="UTMvinst" description="Install macOS on a stopped Apple virtual machine from its IPSW recovery image. All data on the primary drive will be erased, there is no confirmation! Returns once the installation starts, use query install to follow its progress.">
|
||||
<direct-parameter description="Virtual machine to install macOS on." type="virtual machine"/>
|
||||
</command>
|
||||
|
||||
@@ -235,7 +235,7 @@ extension SBObject: UTMScriptingWindow {}
|
||||
@objc optional var name: String { get } // The name of the VM.
|
||||
@objc optional var backend: UTMScriptingBackend { get } // Emulation/virtualization engine used.
|
||||
@objc optional var status: UTMScriptingStatus { get } // Current running status.
|
||||
@objc optional func startSaving(_ saving: Bool, recovery: Bool) // Start a virtual machine or resume a suspended virtual machine.
|
||||
@objc optional func startSaving(_ saving: Bool, recovery: Bool, provisioning: [AnyHashable : Any]!) // Start a virtual machine or resume a suspended virtual machine.
|
||||
@objc optional func install() // Install macOS on a stopped Apple virtual machine from its IPSW recovery image. All data on the primary drive will be erased, there is no confirmation! Returns once the installation starts, use query install to follow its progress.
|
||||
@objc optional func queryInstall() -> [AnyHashable : Any] // Query the progress of the last installation started with install. Raises an error if the installation failed.
|
||||
@objc optional func suspendSaving(_ saving: Bool) // Suspend a running virtual machine to memory.
|
||||
|
||||
@@ -119,6 +119,8 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||
@objc func start(_ command: NSScriptCommand) {
|
||||
let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? true
|
||||
let bootRecoveryMode = command.evaluatedArguments?["bootRecoveryFlag"] as? Bool ?? false
|
||||
let hasProvisioning = command.evaluatedArguments?["provisioningOptions"] != nil
|
||||
let provisioning = command.evaluatedArguments?["provisioningOptions"] as? [AnyHashable : Any]
|
||||
|
||||
withScriptCommand(command) { [self] in
|
||||
var options: UTMVirtualMachineStartOptions = []
|
||||
@@ -126,6 +128,11 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||
guard !isInstalling else {
|
||||
throw ScriptingError.installInProgress
|
||||
}
|
||||
// the first boot cannot be repeated so never fall back to a normal start
|
||||
guard provisioning != nil || !hasProvisioning else {
|
||||
throw ScriptingError.invalidParameter
|
||||
}
|
||||
|
||||
if !shouldSaveState {
|
||||
guard type(of: vm).capabilities.supportsDisposibleMode else {
|
||||
throw ScriptingError.operationNotSupported
|
||||
@@ -139,8 +146,31 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||
options.insert(.bootRecovery)
|
||||
}
|
||||
|
||||
var guestProvisioning: UTMAppleGuestProvisioningOptions?
|
||||
if let provisioning = provisioning {
|
||||
guard let vm = vm as? UTMAppleVirtualMachine else {
|
||||
throw ScriptingError.operationNotSupported
|
||||
}
|
||||
guard let username = provisioning["username"] as? String, !username.isEmpty, let password = provisioning["password"] as? String else {
|
||||
throw ScriptingError.invalidParameter
|
||||
}
|
||||
guard vm.state == .stopped else {
|
||||
throw ScriptingError.operationNotAvailable
|
||||
}
|
||||
let account = UTMAppleGuestProvisioningOptions(fullName: provisioning["fullName"] as? String ?? username,
|
||||
username: username,
|
||||
password: password,
|
||||
logsInAutomatically: provisioning["automaticLogin"] as? Bool ?? false,
|
||||
enablesRemoteLogin: provisioning["remoteLogin"] as? Bool ?? false)
|
||||
// report mistakes before a window is opened
|
||||
try await vm.validateGuestProvisioning(account, options: options)
|
||||
guestProvisioning = account
|
||||
}
|
||||
|
||||
data.run(vm: box, startImmediately: false)
|
||||
if vm.state == .stopped {
|
||||
if let guestProvisioning = guestProvisioning, let vm = vm as? UTMAppleVirtualMachine {
|
||||
try await vm.start(options: options, guestProvisioning: guestProvisioning)
|
||||
} else if vm.state == .stopped {
|
||||
try await vm.start(options: options)
|
||||
} else if vm.state == .paused {
|
||||
try await vm.resume()
|
||||
|
||||
@@ -170,7 +170,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
updateConfigFromRegistry()
|
||||
}
|
||||
|
||||
private func _start(options: UTMVirtualMachineStartOptions) async throws {
|
||||
/// - Parameter guestProvisioning: `VZMacGuestProvisioningOptions` on macOS 27, always nil on older versions
|
||||
private func _start(options: UTMVirtualMachineStartOptions, guestProvisioning: Any? = nil) async throws {
|
||||
let boot = await config.system.boot
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in
|
||||
vmQueue.async {
|
||||
@@ -182,6 +183,14 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
if #available(macOS 13, *), boot.operatingSystem == .macOS {
|
||||
let vzoptions = VZMacOSVirtualMachineStartOptions()
|
||||
vzoptions.startUpFromMacOSRecovery = options.contains(.bootRecovery)
|
||||
if #available(macOS 27, *), let guestProvisioning = guestProvisioning as? VZMacGuestProvisioningOptions {
|
||||
do {
|
||||
try vzoptions.setGuestProvisioning(guestProvisioning)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
apple.start(options: vzoptions) { result in
|
||||
if let result = result {
|
||||
continuation.resume(with: .failure(result))
|
||||
@@ -203,7 +212,27 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
}
|
||||
|
||||
func start(options: UTMVirtualMachineStartOptions = []) async throws {
|
||||
try await start(options: options, guestProvisioning: nil)
|
||||
}
|
||||
|
||||
/// Start the VM and optionally set up the macOS guest without user interaction
|
||||
///
|
||||
/// The guest only reads the provisioning options on the first boot after macOS is installed.
|
||||
/// - Parameters:
|
||||
/// - options: Options for startup
|
||||
/// - guestProvisioning: If set, account to create in the guest. This is never saved.
|
||||
func start(options: UTMVirtualMachineStartOptions, guestProvisioning: UTMAppleGuestProvisioningOptions?) async throws {
|
||||
var vzGuestProvisioning: Any?
|
||||
if let guestProvisioning = guestProvisioning {
|
||||
// this must fail before the error handling below which discards any saved state
|
||||
vzGuestProvisioning = try await validateGuestProvisioning(guestProvisioning, options: options)
|
||||
}
|
||||
// do not suspend between checking and changing the state
|
||||
guard state == .stopped else {
|
||||
if guestProvisioning != nil {
|
||||
// the first boot cannot be repeated so the caller must know it was not set up
|
||||
throw UTMAppleVirtualMachineError.guestProvisioningNotAvailable
|
||||
}
|
||||
return
|
||||
}
|
||||
state = .starting
|
||||
@@ -222,7 +251,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
if isRestoring {
|
||||
try await restoreSnapshot()
|
||||
} else {
|
||||
try await _start(options: options)
|
||||
try await _start(options: options, guestProvisioning: vzGuestProvisioning)
|
||||
}
|
||||
if #available(macOS 15, *) {
|
||||
try await attachExternalDrives()
|
||||
@@ -295,6 +324,44 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that the guest can be provisioned on this start
|
||||
/// - Parameters:
|
||||
/// - guestProvisioning: Account to create in the guest
|
||||
/// - options: Options for startup
|
||||
/// - Returns: `VZMacGuestProvisioningOptions` to pass to `_start`
|
||||
@discardableResult
|
||||
func validateGuestProvisioning(_ guestProvisioning: UTMAppleGuestProvisioningOptions, options: UTMVirtualMachineStartOptions) async throws -> Any {
|
||||
#if os(macOS) && arch(arm64)
|
||||
let boot = await config.system.boot
|
||||
guard #available(macOS 27, *), boot.operatingSystem == .macOS else {
|
||||
throw UTMAppleVirtualMachineError.guestProvisioningNotSupported
|
||||
}
|
||||
// only the first boot after install sets up the guest: not a pending install, a resumed guest, or recoveryOS
|
||||
let isSuspended = await registryEntry.isSuspended
|
||||
guard boot.macRecoveryIpswURL == nil && !isSuspended && !options.contains(.bootRecovery) else {
|
||||
throw UTMAppleVirtualMachineError.guestProvisioningNotAvailable
|
||||
}
|
||||
let vzGuestProvisioning = VZMacGuestProvisioningOptions()
|
||||
vzGuestProvisioning.fullName = guestProvisioning.fullName
|
||||
vzGuestProvisioning.username = guestProvisioning.username
|
||||
vzGuestProvisioning.password = guestProvisioning.password
|
||||
vzGuestProvisioning.logsInAutomatically = guestProvisioning.logsInAutomatically
|
||||
vzGuestProvisioning.enablesRemoteLogin = guestProvisioning.enablesRemoteLogin
|
||||
do {
|
||||
try vzGuestProvisioning.validate()
|
||||
} catch let error as VZError where error.code == .guestProvisioningInvalidFullName {
|
||||
throw UTMAppleVirtualMachineError.guestProvisioningInvalidFullName
|
||||
} catch let error as VZError where error.code == .guestProvisioningInvalidUsername {
|
||||
throw UTMAppleVirtualMachineError.guestProvisioningInvalidUsername
|
||||
} catch let error as VZError where error.code == .guestProvisioningInvalidPassword {
|
||||
throw UTMAppleVirtualMachineError.guestProvisioningInvalidPassword
|
||||
}
|
||||
return vzGuestProvisioning
|
||||
#else
|
||||
throw UTMAppleVirtualMachineError.guestProvisioningNotSupported
|
||||
#endif
|
||||
}
|
||||
|
||||
private func _forceStop() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
vmQueue.async {
|
||||
@@ -1356,6 +1423,22 @@ enum UTMAppleVirtualMachineError: Error {
|
||||
case usbDeviceInUse(String)
|
||||
case usbDeviceNotConnected(String)
|
||||
case cannotSaveInDisposibleMode
|
||||
case guestProvisioningNotSupported
|
||||
case guestProvisioningNotAvailable
|
||||
case guestProvisioningInvalidFullName
|
||||
case guestProvisioningInvalidUsername
|
||||
case guestProvisioningInvalidPassword
|
||||
}
|
||||
|
||||
/// Account to create in a macOS guest on the first boot after install
|
||||
///
|
||||
/// This holds a password so it is intentionally not `Codable` and must not be saved or logged.
|
||||
struct UTMAppleGuestProvisioningOptions {
|
||||
var fullName: String
|
||||
var username: String
|
||||
var password: String
|
||||
var logsInAutomatically: Bool = false
|
||||
var enablesRemoteLogin: Bool = false
|
||||
}
|
||||
|
||||
extension UTMAppleVirtualMachineError: LocalizedError {
|
||||
@@ -1381,6 +1464,16 @@ extension UTMAppleVirtualMachineError: LocalizedError {
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The USB device '%@' is not connected to this virtual machine.", comment: "UTMAppleVirtualMachine"), name)
|
||||
case .cannotSaveInDisposibleMode:
|
||||
return NSLocalizedString("Suspend state cannot be saved when running without saving changes.", comment: "UTMAppleVirtualMachine")
|
||||
case .guestProvisioningNotSupported:
|
||||
return NSLocalizedString("Setting up the guest automatically requires a macOS virtual machine and macOS 27 or later on Apple Silicon.", comment: "UTMAppleVirtualMachine")
|
||||
case .guestProvisioningNotAvailable:
|
||||
return NSLocalizedString("The guest can only be set up automatically on the first start after macOS is installed. It cannot be set up before macOS is installed, from a saved state, or in recovery mode.", comment: "UTMAppleVirtualMachine")
|
||||
case .guestProvisioningInvalidFullName:
|
||||
return NSLocalizedString("The full name for the guest account is not valid.", comment: "UTMAppleVirtualMachine")
|
||||
case .guestProvisioningInvalidUsername:
|
||||
return NSLocalizedString("The username for the guest account is not valid.", comment: "UTMAppleVirtualMachine")
|
||||
case .guestProvisioningInvalidPassword:
|
||||
return NSLocalizedString("The password for the guest account is not valid.", comment: "UTMAppleVirtualMachine")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-1
@@ -246,9 +246,93 @@ extension UTMCtl {
|
||||
@Flag(help: "Boot a VM in recovery mode.")
|
||||
var recovery: Bool = false
|
||||
|
||||
struct Provisioning: ParsableArguments {
|
||||
@Option(name: .customLong("provision-username"), help: "Set up a macOS guest with this account on the first start after macOS is installed. Requires macOS 27 or later on both the host and the guest.")
|
||||
var username: String?
|
||||
|
||||
@Option(name: .customLong("provision-full-name"), help: "Full name of the guest account. Defaults to the username.")
|
||||
var fullName: String?
|
||||
|
||||
@Option(name: .customLong("provision-password"), help: "Password of the guest account. Other processes can see this, use --provision-password-stdin to avoid that.")
|
||||
var password: String?
|
||||
|
||||
@Flag(name: .customLong("provision-password-stdin"), help: "Read the password of the guest account from standard input.")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
@Flag(name: .customLong("provision-auto-login"), help: "Log in to the guest account automatically when the guest starts up.")
|
||||
var autoLogin: Bool = false
|
||||
|
||||
@Flag(name: .customLong("provision-ssh"), help: "Turn on Remote Login (SSH) in the guest.")
|
||||
var ssh: Bool = false
|
||||
|
||||
struct InvalidProvisioningError: LocalizedError {
|
||||
var errorDescription: String?
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
guard username != nil else {
|
||||
guard fullName == nil && password == nil && !passwordStdin && !autoLogin && !ssh else {
|
||||
throw InvalidProvisioningError(errorDescription: "You must specify --provision-username to set up the guest")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard (password != nil) != passwordStdin else {
|
||||
throw InvalidProvisioningError(errorDescription: "You must specify one of: --provision-password or --provision-password-stdin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptionGroup var provisioning: Provisioning
|
||||
|
||||
struct ProvisioningRecoveryError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
"You cannot set up the guest when booting in recovery mode"
|
||||
}
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
guard !recovery || provisioning.username == nil else {
|
||||
throw ProvisioningRecoveryError()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a password from standard input without showing it when typed at a terminal
|
||||
static func readPassword() throws -> String {
|
||||
if isatty(STDIN_FILENO) != 0 {
|
||||
var buffer = [CChar](repeating: 0, count: 1024)
|
||||
guard readpassphrase("Password: ", &buffer, buffer.count, 0) != nil else {
|
||||
throw Provisioning.InvalidProvisioningError(errorDescription: "Failed to read the password")
|
||||
}
|
||||
return String(cString: buffer)
|
||||
}
|
||||
let data = try FileHandle.standardInput.readToEnd() ?? Data()
|
||||
// a lossy conversion would create an account with a password nobody knows
|
||||
guard var password = String(data: data, encoding: .utf8) else {
|
||||
throw Provisioning.InvalidProvisioningError(errorDescription: "The password must be valid UTF-8 text")
|
||||
}
|
||||
if password.last?.isNewline == true {
|
||||
password.removeLast()
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
func run(with application: UTMScriptingApplication) throws {
|
||||
let vm = try virtualMachine(forIdentifier: identifer, in: application)
|
||||
vm.startSaving!(!disposable, recovery: recovery)
|
||||
var provisioningOptions: [AnyHashable : Any]?
|
||||
if let username = provisioning.username {
|
||||
var password = provisioning.password ?? ""
|
||||
if provisioning.passwordStdin {
|
||||
password = try Self.readPassword()
|
||||
}
|
||||
provisioningOptions = ["username": username,
|
||||
"password": password,
|
||||
"automaticLogin": provisioning.autoLogin,
|
||||
"remoteLogin": provisioning.ssh]
|
||||
if let fullName = provisioning.fullName {
|
||||
provisioningOptions!["fullName"] = fullName
|
||||
}
|
||||
}
|
||||
vm.startSaving!(!disposable, recovery: recovery, provisioning: provisioningOptions)
|
||||
if attach {
|
||||
print("WARNING: attach command is not implemented yet!")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user