From d0a6a1e363613fdb4ed03ed756afa3b5c879ed90 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 17:35:00 +0000 Subject: [PATCH 1/9] add recovery mode option --- README.md | 13 +++--- src/Commands/Run.swift | 7 ++- src/LumeController.swift | 4 +- src/VM/VM.swift | 16 ++++--- .../VMVirtualizationService.swift | 43 ++++++++++++++----- 5 files changed, 58 insertions(+), 25 deletions(-) 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/src/Commands/Run.swift b/src/Commands/Run.swift index a8c9f3ae..bf606f48 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 @@ -82,4 +85,4 @@ struct Run: AsyncParsableCommand { vncPort: vncPort ) } -} \ No newline at end of file +} diff --git a/src/LumeController.swift b/src/LumeController.swift index b4b04106..697ea7c8 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 { 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..b0fbe88b 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,52 @@ 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) + // Check if it's a macOS VM and should start in recovery mode + if #available(macOS 14.0, *), + let macVM = virtualMachine as? VZMacOSVirtualMachine, + recoveryMode { + + let startOptions = VZMacOSVirtualMachineStartOptions() + startOptions.startUpFrom = .useRecoveryPartition // Boot into recovery mode + + Logger.info("Starting macOS VM in Recovery Mode") + macVM.start(options: startOptions) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } 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 +233,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 +349,4 @@ final class LinuxVirtualizationService: BaseVirtualizationService { let vzConfig = try Self.createConfiguration(configuration) super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig)) } -} \ No newline at end of file +} From 896710b361644a907054dc448f6755c2ab21c651 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 18:40:03 +0000 Subject: [PATCH 2/9] fix starting in recovery mode --- .../VMVirtualizationService.swift | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Virtualization/VMVirtualizationService.swift b/src/Virtualization/VMVirtualizationService.swift index b0fbe88b..3db81773 100644 --- a/src/Virtualization/VMVirtualizationService.swift +++ b/src/Virtualization/VMVirtualizationService.swift @@ -45,21 +45,14 @@ class BaseVirtualizationService: VMVirtualizationService { func start() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in Task { @MainActor in - // Check if it's a macOS VM and should start in recovery mode - if #available(macOS 14.0, *), - let macVM = virtualMachine as? VZMacOSVirtualMachine, - recoveryMode { - + if #available(macOS 13, *) { let startOptions = VZMacOSVirtualMachineStartOptions() - startOptions.startUpFrom = .useRecoveryPartition // Boot into recovery mode - - Logger.info("Starting macOS VM in Recovery Mode") - macVM.start(options: startOptions) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): + startOptions.startUpFromMacOSRecovery = recoveryMode + virtualMachine.start(options: startOptions) { error in + if let error = error { continuation.resume(throwing: error) + } else { + continuation.resume() } } } else { From d02147d1e844381452816a57aa723ce0c53ed8cd Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 19:33:21 +0000 Subject: [PATCH 3/9] add recoveryMode in tests --- tests/Mocks/MockVM.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 +} From ad4e0041a5e78e00572eccdb07deb57e3a9faa7e Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 19:50:54 +0000 Subject: [PATCH 4/9] Add missing recovery mode param --- src/Commands/Run.swift | 3 ++- src/LumeController.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Commands/Run.swift b/src/Commands/Run.swift index bf606f48..e94ae7d3 100644 --- a/src/Commands/Run.swift +++ b/src/Commands/Run.swift @@ -82,7 +82,8 @@ struct Run: AsyncParsableCommand { mount: mount, registry: registry, organization: organization, - vncPort: vncPort + vncPort: vncPort, + recoveryMode: recoveryMode ) } } diff --git a/src/LumeController.swift b/src/LumeController.swift index 697ea7c8..91e82287 100644 --- a/src/LumeController.swift +++ b/src/LumeController.swift @@ -288,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) From 6c626600408c64c8fce2bf97786333fbc08437f0 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 19:54:54 +0000 Subject: [PATCH 5/9] set recovery mode false in tests --- tests/VMTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 6bb997dbaf42d49b3a1e9d86bc5315979c21d8d7 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 19:57:54 +0000 Subject: [PATCH 6/9] add log about starting in recovery mode --- src/Virtualization/VMVirtualizationService.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Virtualization/VMVirtualizationService.swift b/src/Virtualization/VMVirtualizationService.swift index 3db81773..7fad347b 100644 --- a/src/Virtualization/VMVirtualizationService.swift +++ b/src/Virtualization/VMVirtualizationService.swift @@ -48,6 +48,9 @@ class BaseVirtualizationService: VMVirtualizationService { 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) From cfb74b0da8f440e136f4ecd3cbf9c80bd59db52d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 20:02:22 +0000 Subject: [PATCH 7/9] Add recovery mode option in API server --- src/Server/Handlers.swift | 11 +++++++---- src/Server/Requests.swift | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Server/Handlers.swift b/src/Server/Handlers.swift index af287dd8..b71a25a8 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: false) 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 From c7460ac7f21b451ca4a3dc83a90fa00d7b86e55d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 20:03:25 +0000 Subject: [PATCH 8/9] add recovery mode example in API reference --- docs/API-Reference.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/API-Reference.md b/docs/API-Reference.md index 221b8e17..a00285af 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 ``` From c2bae88699d0371e4d75c9e701c34000f81fa004 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 10 Mar 2025 20:24:07 +0000 Subject: [PATCH 9/9] update API docs --- docs/API-Reference.md | 2 +- src/Server/Handlers.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API-Reference.md b/docs/API-Reference.md index a00285af..f6afefdd 100644 --- a/docs/API-Reference.md +++ b/docs/API-Reference.md @@ -44,7 +44,7 @@ curl --connect-timeout 6000 \ "readOnly": false } ], - recoveryMode: false + "recoveryMode": false }' \ http://localhost:3000/lume/vms/lume_vm/run ``` diff --git a/src/Server/Handlers.swift b/src/Server/Handlers.swift index b71a25a8..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, recoveryMode: false) + let request = body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) } ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil, recoveryMode: nil) do { let dirs = try request.parse()