diff --git a/README.md b/README.md index 3ec8c02e..e83688e9 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,13 @@ Command Options: --ipsw Path to IPSW file or 'latest' for macOS VMs run: - --no-display Do not start the VNC client app - --shared-dir Share directory with VM (format: path[:ro|rw]) - --mount For Linux VMs only, attach a read-only disk image - --registry Container registry URL (default: ghcr.io) - --organization Organization to pull from (default: trycua) - --vnc-port Port to use for the VNC server (default: 0 for auto-assign) + --no-display Do not start the VNC client app + --shared-dir Share directory with VM (format: path[:ro|rw]) + --mount For Linux VMs only, attach a read-only disk image + --registry Container registry URL (default: ghcr.io) + --organization Organization to pull from (default: trycua) + --vnc-port Port to use for the VNC server (default: 0 for auto-assign) + --recovery-mode For MacOS VMs only, start VM in recovery mode (default: false) set: --cpu New number of CPU cores (e.g., 4) diff --git a/docs/API-Reference.md b/docs/API-Reference.md index 221b8e17..f6afefdd 100644 --- a/docs/API-Reference.md +++ b/docs/API-Reference.md @@ -43,7 +43,8 @@ curl --connect-timeout 6000 \ "hostPath": "~/Projects", "readOnly": false } - ] + ], + "recoveryMode": false }' \ http://localhost:3000/lume/vms/lume_vm/run ``` diff --git a/src/Commands/Run.swift b/src/Commands/Run.swift index a8c9f3ae..e94ae7d3 100644 --- a/src/Commands/Run.swift +++ b/src/Commands/Run.swift @@ -27,7 +27,10 @@ struct Run: AsyncParsableCommand { @Option(name: [.customLong("vnc-port")], help: "Port to use for the VNC server. Defaults to 0 (auto-assign)") var vncPort: Int = 0 - + + @Option(help: "For MacOS VMs only, boot into the VM in recovery mode") + var recoveryMode: Bool = false + private var parsedSharedDirectories: [SharedDirectory] { get throws { try sharedDirectories.map { dirString -> SharedDirectory in @@ -79,7 +82,8 @@ struct Run: AsyncParsableCommand { mount: mount, registry: registry, organization: organization, - vncPort: vncPort + vncPort: vncPort, + recoveryMode: recoveryMode ) } -} \ No newline at end of file +} diff --git a/src/LumeController.swift b/src/LumeController.swift index b4b04106..91e82287 100644 --- a/src/LumeController.swift +++ b/src/LumeController.swift @@ -256,7 +256,8 @@ final class LumeController { mount: Path? = nil, registry: String = "ghcr.io", organization: String = "trycua", - vncPort: Int = 0 + vncPort: Int = 0, + recoveryMode: Bool = false ) async throws { let normalizedName = normalizeVMName(name: name) Logger.info( @@ -267,6 +268,7 @@ final class LumeController { "shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))", "mount": mount?.path ?? "none", "vnc_port": "\(vncPort)", + "recovery_mode": "\(recoveryMode)", ]) do { @@ -286,7 +288,7 @@ final class LumeController { let vm = try get(name: normalizedName) SharedVM.shared.setVM(name: normalizedName, vm: vm) - try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort) + try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort, recoveryMode: recoveryMode) Logger.info("VM started successfully", metadata: ["name": normalizedName]) } catch { SharedVM.shared.removeVM(name: normalizedName) diff --git a/src/Server/Handlers.swift b/src/Server/Handlers.swift index af287dd8..affecfd4 100644 --- a/src/Server/Handlers.swift +++ b/src/Server/Handlers.swift @@ -161,7 +161,7 @@ extension Server { } func handleRunVM(name: String, body: Data?) async throws -> HTTPResponse { - let request = body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) } ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil) + let request = body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) } ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil, recoveryMode: nil) do { let dirs = try request.parse() @@ -170,7 +170,8 @@ extension Server { startVM( name: name, noDisplay: request.noDisplay ?? false, - sharedDirectories: dirs + sharedDirectories: dirs, + recoveryMode: request.recoveryMode ?? false ) // Return response immediately @@ -299,7 +300,8 @@ extension Server { nonisolated private func startVM( name: String, noDisplay: Bool, - sharedDirectories: [SharedDirectory] = [] + sharedDirectories: [SharedDirectory] = [], + recoveryMode: Bool = false ) { Task.detached { @MainActor @Sendable in Logger.info("Starting VM in background", metadata: ["name": name]) @@ -308,7 +310,8 @@ extension Server { try await vmController.runVM( name: name, noDisplay: noDisplay, - sharedDirectories: sharedDirectories + sharedDirectories: sharedDirectories, + recoveryMode: recoveryMode ) Logger.info("VM started successfully in background", metadata: ["name": name]) } catch { diff --git a/src/Server/Requests.swift b/src/Server/Requests.swift index 49a4293a..e3f86a61 100644 --- a/src/Server/Requests.swift +++ b/src/Server/Requests.swift @@ -5,6 +5,7 @@ import Virtualization struct RunVMRequest: Codable { let noDisplay: Bool? let sharedDirectories: [SharedDirectoryRequest]? + let recoveryMode: Bool? struct SharedDirectoryRequest: Codable { let hostPath: String diff --git a/src/VM/VM.swift b/src/VM/VM.swift index 8f79cead..8d736c62 100644 --- a/src/VM/VM.swift +++ b/src/VM/VM.swift @@ -88,7 +88,7 @@ class VM { // MARK: - VM Lifecycle Management - func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0) async throws { + func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0, recoveryMode: Bool = false) async throws { guard vmDirContext.initialized else { throw VMError.notInitialized(vmDirContext.name) } @@ -112,7 +112,8 @@ class VM { "sharedDirectories": sharedDirectories.map( { $0.string } ).joined(separator: ", "), - "vncPort": "\(vncPort)" + "vncPort": "\(vncPort)", + "recoveryMode": "\(recoveryMode)" ]) // Create and configure the VM @@ -122,7 +123,8 @@ class VM { memorySize: memorySize, display: vmDirContext.config.display.string, sharedDirectories: sharedDirectories, - mount: mount + mount: mount, + recoveryMode: recoveryMode ) virtualizationService = try virtualizationServiceFactory(config) @@ -368,7 +370,8 @@ class VM { memorySize: UInt64, display: String, sharedDirectories: [SharedDirectory] = [], - mount: Path? = nil + mount: Path? = nil, + recoveryMode: Bool = false ) throws -> VMVirtualizationServiceContext { return VMVirtualizationServiceContext( cpuCount: cpuCount, @@ -380,7 +383,8 @@ class VM { machineIdentifier: vmDirContext.config.machineIdentifier, macAddress: vmDirContext.config.macAddress!, diskPath: vmDirContext.diskPath, - nvramPath: vmDirContext.nvramPath + nvramPath: vmDirContext.nvramPath, + recoveryMode: recoveryMode ) } @@ -400,4 +404,4 @@ class VM { func finalize(to name: String, home: Home) throws { try vmDirContext.finalize(to: name) } -} \ No newline at end of file +} diff --git a/src/Virtualization/VMVirtualizationService.swift b/src/Virtualization/VMVirtualizationService.swift index c10e2fd1..7fad347b 100644 --- a/src/Virtualization/VMVirtualizationService.swift +++ b/src/Virtualization/VMVirtualizationService.swift @@ -13,6 +13,7 @@ struct VMVirtualizationServiceContext { let macAddress: String let diskPath: Path let nvramPath: Path + let recoveryMode: Bool } /// Protocol defining the interface for virtualization operations @@ -30,30 +31,48 @@ protocol VMVirtualizationService { @MainActor class BaseVirtualizationService: VMVirtualizationService { let virtualMachine: VZVirtualMachine + let recoveryMode: Bool // Store whether we should start in recovery mode var state: VZVirtualMachine.State { virtualMachine.state } - init(virtualMachine: VZVirtualMachine) { + init(virtualMachine: VZVirtualMachine, recoveryMode: Bool = false) { self.virtualMachine = virtualMachine + self.recoveryMode = recoveryMode } func start() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in Task { @MainActor in - virtualMachine.start { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) + if #available(macOS 13, *) { + let startOptions = VZMacOSVirtualMachineStartOptions() + startOptions.startUpFromMacOSRecovery = recoveryMode + if recoveryMode { + Logger.info("Starting VM in recovery mode") + } + virtualMachine.start(options: startOptions) { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } else { + Logger.info("Starting VM in normal mode") + virtualMachine.start { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } } } } } } - + func stop() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in virtualMachine.stop { error in @@ -210,7 +229,7 @@ final class DarwinVirtualizationService: BaseVirtualizationService { init(configuration: VMVirtualizationServiceContext) throws { let vzConfig = try Self.createConfiguration(configuration) - super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig)) + super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig), recoveryMode: configuration.recoveryMode) } func installMacOS(imagePath: Path, progressHandler: (@Sendable (Double) -> Void)?) async throws { @@ -326,4 +345,4 @@ final class LinuxVirtualizationService: BaseVirtualizationService { let vzConfig = try Self.createConfiguration(configuration) super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig)) } -} \ No newline at end of file +} diff --git a/tests/Mocks/MockVM.swift b/tests/Mocks/MockVM.swift index 3ff2d0e7..907252da 100644 --- a/tests/Mocks/MockVM.swift +++ b/tests/Mocks/MockVM.swift @@ -18,13 +18,13 @@ class MockVM: VM { try vmDirContext.saveConfig() } - override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0) async throws { + override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0, recoveryMode: Bool = false) async throws { mockIsRunning = true - try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort) + try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort, recoveryMode: recoveryMode) } override func stop() async throws { mockIsRunning = false try await super.stop() } -} \ No newline at end of file +} diff --git a/tests/VMTests.swift b/tests/VMTests.swift index 50961a9a..09a56e06 100644 --- a/tests/VMTests.swift +++ b/tests/VMTests.swift @@ -95,7 +95,7 @@ func testVMRunAndStop() async throws { // Test running VM let runTask = Task { - try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil, vncPort: 0) + try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil, vncPort: 0, recoveryMode: false) } // Give the VM time to start