Merge pull request #7881 from utmapp/feature/apple-guest-provisioning

scripting: provision macOS guests on first boot
This commit is contained in:
osy
2026-09-19 22:45:12 -05:00
committed by GitHub
5 changed files with 232 additions and 5 deletions
+20
View File
@@ -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>
+1 -1
View File
@@ -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.
+31 -1
View File
@@ -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()
+95 -2
View File
@@ -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
View File
@@ -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!")
}