Merge pull request #37 from aktech/recovery-mode-option

Add recovery mode option
This commit is contained in:
f-trycua
2025-03-10 14:17:41 -07:00
committed by GitHub
10 changed files with 71 additions and 36 deletions

View File

@@ -65,12 +65,13 @@ Command Options:
--ipsw <path> Path to IPSW file or 'latest' for macOS VMs
run:
--no-display Do not start the VNC client app
--shared-dir <dir> Share directory with VM (format: path[:ro|rw])
--mount <path> For Linux VMs only, attach a read-only disk image
--registry <url> Container registry URL (default: ghcr.io)
--organization <org> Organization to pull from (default: trycua)
--vnc-port <port> Port to use for the VNC server (default: 0 for auto-assign)
--no-display Do not start the VNC client app
--shared-dir <dir> Share directory with VM (format: path[:ro|rw])
--mount <path> For Linux VMs only, attach a read-only disk image
--registry <url> Container registry URL (default: ghcr.io)
--organization <org> Organization to pull from (default: trycua)
--vnc-port <port> Port to use for the VNC server (default: 0 for auto-assign)
--recovery-mode <boolean> For MacOS VMs only, start VM in recovery mode (default: false)
set:
--cpu <cores> New number of CPU cores (e.g., 4)

View File

@@ -43,7 +43,8 @@ curl --connect-timeout 6000 \
"hostPath": "~/Projects",
"readOnly": false
}
]
],
"recoveryMode": false
}' \
http://localhost:3000/lume/vms/lume_vm/run
```

View File

@@ -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
)
}
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -5,6 +5,7 @@ import Virtualization
struct RunVMRequest: Codable {
let noDisplay: Bool?
let sharedDirectories: [SharedDirectoryRequest]?
let recoveryMode: Bool?
struct SharedDirectoryRequest: Codable {
let hostPath: String

View File

@@ -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)
}
}
}

View File

@@ -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<Void, Error>) 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<Void, Error>) 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))
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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